First version
This commit is contained in:
parent
2dc1bbe0af
commit
50f8b8cfba
33 changed files with 9428 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
target
|
||||
2330
Cargo.lock
generated
Normal file
2330
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
18
Cargo.toml
Normal file
18
Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/player",
|
||||
"crates/library",
|
||||
"crates/tui",
|
||||
"crates/nsm",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
215
README.en.md
Normal file
215
README.en.md
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
# nsm
|
||||
|
||||
A terminal music player written in Rust — something like [cmus](https://cmus.github.io/), but built from scratch, with an equalizer, cue sheet support, internet radio, and SMB shares.
|
||||
|
||||
*[Читать по-русски](README.md)*
|
||||
|
||||
## Features
|
||||
|
||||
- **Library**: recursive scanning of nested folders, fuzzy search (title/artist/album), grouping by album
|
||||
- **Playback**: open a file directly (plays the whole album in order) or add to a playlist; multiple playlists with reordering and deletion
|
||||
- **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
|
||||
- **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) via the system's gvfs — no SMB protocol implemented inside the player itself
|
||||
- **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
|
||||
|
||||
## Supported formats
|
||||
|
||||
Anything [symphonia](https://github.com/pdeltuvia/symphonia) can decode: **MP3, FLAC, ALAC/AAC (in `.m4a`), WAV, AIFF, OGG (Vorbis), MKV/WebM**.
|
||||
|
||||
**Not supported: Opus** — symphonia has no native Opus decoder in any feature set (only third-party libopus bindings, which aren't used here). `.opus` files are skipped by the library scanner.
|
||||
|
||||
## Building
|
||||
|
||||
### System dependencies
|
||||
|
||||
**Linux** — ALSA headers are required (for audio output via [cpal](https://github.com/RustAudio/cpal)):
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
sudo apt-get install libasound2-dev pkg-config
|
||||
|
||||
# ALT Linux
|
||||
sudo apt-get install libalsa-devel
|
||||
|
||||
# Fedora
|
||||
sudo dnf install alsa-lib-devel
|
||||
```
|
||||
|
||||
**macOS** *(untested — this project has only been built and tested on Linux)* — no separate audio dependency needed, `cpal` talks to CoreAudio directly. Just the standard build tools:
|
||||
```bash
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
**Windows** *(untested — this project has only been built and tested on Linux)* — no separate audio dependency needed either (`cpal` uses WASAPI); a normal Rust install via [rustup](https://rustup.rs) (MSVC toolchain) should be enough. For cover art to render well you'll want a modern terminal with true color support — Windows Terminal is recommended; the image protocols (kitty/sixel/iTerm2) are unlikely to be supported there, so cover art will fall back to block-graphics rendering.
|
||||
|
||||
**The SMB feature (`c` in Library) only works on Linux** — it's built entirely on `gio`/gvfs (the GNOME stack), which doesn't exist on macOS or Windows. On those systems, trying to connect an SMB share through nsm will just return a clear error ("is gvfs installed?") rather than crash — network shares there are normally mounted through Finder/Explorer instead.
|
||||
|
||||
No extra system dependencies are needed on any OS for internet radio (**SomaFM**, **Radio Paradise**, etc.) or MusicBrainz — TLS is handled by `rustls` (pure Rust, no OpenSSL).
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
git clone <this-repo-url>
|
||||
cd nsm
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
The binary will be at `target/release/nsm` (on Windows — `target\release\nsm.exe`).
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
./target/release/nsm /path/to/music
|
||||
```
|
||||
|
||||
If no path is given, it defaults to `~/Music`.
|
||||
|
||||
## Controls
|
||||
|
||||
### Global keys (any tab)
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `Space` | play/pause |
|
||||
| `N` / `P` | next / previous track |
|
||||
| `s` | toggle shuffle |
|
||||
| `r` | repeat (off → all → single track → off) |
|
||||
| `m` | mute |
|
||||
| `[` / `]` | speed down / up |
|
||||
| `←` / `→` | seek −5s / +5s |
|
||||
| `e` | focus the equalizer |
|
||||
| `h` | toggle listening history tracking |
|
||||
| `v` | toggle cover art / spectrum analyzer |
|
||||
| `z` | expand cover art / spectrum to fullscreen |
|
||||
| `F` | toggle fade-out at track end |
|
||||
| `G` | toggle loudness normalization |
|
||||
| `I` | cycle cover art rendering protocol (if auto-detection got it wrong) |
|
||||
| `Tab` | next tab |
|
||||
| `q` | quit |
|
||||
|
||||
### Equalizer (after `e`)
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `←` / `→` | select band |
|
||||
| `↑` / `↓` | ±1dB on selected band |
|
||||
| `0` | reset current band |
|
||||
| `R` | reset all bands |
|
||||
| `Esc` / `e` | leave equalizer focus |
|
||||
|
||||
### Library
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `j`/`k`, `↓`/`↑` | navigate |
|
||||
| `Enter` | expand album / play |
|
||||
| `Esc` | collapse album |
|
||||
| `/` | search |
|
||||
| `a` | add to playlist |
|
||||
| `c` | connect an SMB share |
|
||||
| `L` | add another folder to the library |
|
||||
| `C` | clear the library (doesn't touch files on disk) |
|
||||
|
||||
### Playlists
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `j`/`k` | navigate |
|
||||
| `Enter` | open playlist / play from here |
|
||||
| `n` | new playlist |
|
||||
| `J`/`K` | move track down/up |
|
||||
| `d` | remove track from playlist |
|
||||
| `Esc` | back to playlist list |
|
||||
|
||||
### Queue
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `j`/`k` | navigate |
|
||||
| `Enter` | play from selected item |
|
||||
|
||||
### Radio
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `j`/`k` | navigate |
|
||||
| `Enter` | play station |
|
||||
| `u` | add your own station by URL |
|
||||
| `o` | search stations online |
|
||||
| `d` | remove a (custom) station |
|
||||
|
||||
### Stats
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `p` | cycle period (day → week → month) |
|
||||
| `x` | reset history (press twice in a row to confirm) |
|
||||
|
||||
### New Releases
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `p` | cycle period (day → week → month) |
|
||||
| `f` | check new releases for all artists in your library (can take a while, see below) |
|
||||
| `a` | manually check one artist by name (doesn't need to be in your library) — adds to the already-shown list |
|
||||
|
||||
In text-input fields — `Enter` confirms, `Esc` cancels, `Backspace` deletes.
|
||||
|
||||
## Architecture
|
||||
|
||||
A Cargo workspace of four crates:
|
||||
|
||||
```
|
||||
crates/
|
||||
├── player/ — playback engine: decoding (symphonia), audio output (cpal),
|
||||
│ equalizer, speed resampling, queue, internet radio
|
||||
├── library/ — library scanning, tags (lofty), cue sheets, playlists,
|
||||
│ radio stations, SMB
|
||||
├── tui/ — ratatui-based interface: tabs, cover art, equalizer, event loop
|
||||
└── nsm/ — thin binary tying everything together
|
||||
```
|
||||
|
||||
`player` and `library` don't know about each other — `tui` wires them together via `player::QueueItem`.
|
||||
|
||||
## Listening history
|
||||
|
||||
Stored locally in `~/.config/nsm/history.jsonl` (one JSON line per entry: artist, track, date/time). Never sent anywhere. Asked for permission on first launch; can be toggled on/off anytime with `h`, and the **Stats** tab shows a chart by hour/day, a list of exactly what was played, and lets you wipe it all (press `x` twice to confirm).
|
||||
|
||||
## Mojibake in old tags
|
||||
|
||||
Many old mp3 files (especially with 2000s-era tags) store Cyrillic text in ID3v1 using CP1251, but this isn't formally declared anywhere — most libraries (including ours, by default) read such tags as Latin-1, producing "Ïåñíÿ" instead of "Песня". `nsm` detects this heuristically (code points ≤0xFF, then checks the Cyrillic-letter ratio after re-decoding as CP1251) and fixes it automatically, without touching text that's already proper UTF-8.
|
||||
|
||||
## Cover art
|
||||
|
||||
The rendering protocol (kitty/sixel/iTerm2/halfblocks) is auto-detected based on your terminal. If detection gets it wrong (e.g. showing low resolution on a terminal that actually supports kitty graphics) — press `I` to manually cycle through protocols; your choice is remembered for next time.
|
||||
|
||||
## New releases
|
||||
|
||||
The **New Releases** tab explicitly shows releases **only for artists already in your library** — this is not a global "what's trending" chart (rateyourmusic.com actively blocks bots, and honestly scraping it wasn't feasible — see discussion). Instead it uses the open [MusicBrainz](https://musicbrainz.org) database, no API keys needed.
|
||||
|
||||
Because of MusicBrainz's rate limit (no more than one request per second), checking a large library can take a while — the progress line shows which artist is currently being checked. For very prolific artists (many singles/reissues over the years), this may take several requests per artist — MusicBrainz doesn't guarantee results are sorted by date, so the full discography is paged through rather than just the first 100 entries (otherwise a new release could randomly be missed). Results are cached to `~/.config/nsm/new_releases_cache.json`, so relaunching `nsm` shows the last result immediately without hitting the network; refresh manually with `f`.
|
||||
|
||||
**Important scope note**: the tab only shows releases from the **last day/week/month** (toggle with `p`) — it's literally "what came out recently", not the artist's whole discography. If your favorite artist hasn't released anything in the last 30 days (true for most artists most of the time), their list will be empty — that's expected, not a bug.
|
||||
|
||||
## Loudness normalization
|
||||
|
||||
Toggle with `G`. If the file has a ReplayGain tag (`REPLAYGAIN_TRACK_GAIN` and equivalents — read universally via `lofty` across FLAC/MP3/OGG/MP4), that exact value is used. If there's no tag, an adaptive leveler (AGC) kicks in instead: it continuously measures the loudness of the last 1-3 seconds and smoothly adjusts gain toward a target. This is **not the same** as true ReplayGain (computed in advance over the whole track) — the first few seconds of a track won't be perfectly leveled, but it converges quickly after that.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **Only built and tested on Linux** — macOS/Windows builds haven't been verified; the SMB feature fundamentally won't work there (see above)
|
||||
|
||||
- **Opus is not decoded** (see "Supported formats" above)
|
||||
- **Playlists don't store cue track ranges** — adding a cue track to a playlist saves the whole underlying file, not the specific virtual track
|
||||
- Minimum terminal size is 70×18; below that a warning is shown instead of a broken layout
|
||||
- Connecting to SMB shares is a blocking call on the UI thread; the interface will briefly freeze during the operation (radio search and new-releases checks already run in the background and don't block)
|
||||
- Multi-file cue sheets (a rare case) are only supported using the first `FILE` entry in the sheet
|
||||
229
README.md
Normal file
229
README.md
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
# nsm
|
||||
|
||||
Терминальный музыкальный плеер на Rust — что-то вроде [cmus](https://cmus.github.io/), но с нуля, с эквалайзером, поддержкой cue-листов, интернет-радио и SMB-шар.
|
||||
|
||||
*[Read this in English](README.en.md)*
|
||||
|
||||
## Возможности
|
||||
|
||||
- **Библиотека**: рекурсивное сканирование вложенных папок, поиск (нечёткий, по названию/исполнителю/альбому), группировка по альбомам
|
||||
- **Воспроизведение**: открыть файл напрямую (весь альбом подряд) или добавить в плейлист; несколько плейлистов с возможностью переставлять и удалять треки
|
||||
- **Транспорт**: shuffle, повтор (выкл/один трек/всё), изменение скорости воспроизведения (0.25×–1.75×), mute, честный seek (перемотка) с сохранением позиции
|
||||
- **`.cue`-листы**: один физический файл (например, целый альбом одним FLAC) корректно распознаётся как несколько треков с правильными названиями и границами
|
||||
- **Эквалайзер**: 8 полос, свои биквадратные фильтры
|
||||
- **Обложка**: отображается прямо в терминале (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-шары**: подключение к сетевым папкам (в т.ч. анонимно/гостевой доступ) через системный gvfs — без реализации протокола внутри плеера
|
||||
- **История прослушиваний**: при первом запуске спрашивает, запоминать ли прослушанное; статистика (артист/трек/дата) — диаграммой по часам за сегодня, по дням за неделю/месяц; включается/выключается в любой момент, сбрасывается с подтверждением
|
||||
- **Новые релизы**: проверка новых альбомов/EP/синглов **у артистов, уже имеющихся в твоей библиотеке**, через открытую базу [MusicBrainz](https://musicbrainz.org) (без ключей API) — не глобальный чарт, а именно "что вышло нового у тех, кого ты слушаешь"
|
||||
- **Раскладка**: хоткеи работают одинаково что на английской, что на русской раскладке
|
||||
|
||||
## Форматы
|
||||
|
||||
Поддерживается всё, что умеет декодировать [symphonia](https://github.com/pdeltuvia/symphonia): **MP3, FLAC, ALAC/AAC (в `.m4a`), WAV, AIFF, OGG (Vorbis), MKV/WebM**.
|
||||
|
||||
**Не поддерживается: Opus** — у symphonia нет собственного Opus-декодера ни в одном наборе фич (только сторонние биндинги к libopus, которых в проекте нет). `.opus`-файлы не индексируются сканером библиотеки.
|
||||
|
||||
## Сборка
|
||||
|
||||
### Системные зависимости
|
||||
|
||||
**Linux** — нужны заголовки ALSA (для аудио-вывода через [cpal](https://github.com/RustAudio/cpal)):
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
sudo apt-get install libasound2-dev pkg-config
|
||||
|
||||
# ALT Linux
|
||||
sudo apt-get install libalsa-devel
|
||||
|
||||
# Fedora
|
||||
sudo dnf install alsa-lib-devel
|
||||
```
|
||||
|
||||
**macOS** *(не проверено — собиралось и тестировалось только на Linux)* — отдельных аудио-зависимостей не нужно, `cpal` использует системный CoreAudio напрямую. Нужны стандартные инструменты сборки:
|
||||
```bash
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
**Windows** *(не проверено — собиралось и тестировалось только на Linux)* — отдельных аудио-зависимостей тоже не нужно (`cpal` использует WASAPI), достаточно обычной установки Rust через [rustup](https://rustup.rs) (MSVC toolchain). Для нормального отображения обложки в терминале нужен современный эмулятор с поддержкой true color — рекомендуется Windows Terminal; протоколы отображения картинок (kitty/sixel/iTerm2) там, скорее всего, не поддерживаются, так что обложка будет через block-графику как fallback.
|
||||
|
||||
**SMB-фича (`c` в Library) работает только на Linux** — она целиком построена на `gio`/gvfs (GNOME-стек), которого нет ни на macOS, ни на Windows. На этих системах при попытке подключить SMB-шару через nsm просто вернётся понятная ошибка ("is gvfs installed?"), без краша — сетевые папки там штатно монтируются через Finder/Проводник, не через nsm.
|
||||
|
||||
**Если получаешь ошибку "volume doesn't implement mount" / "том не поддерживает монтирование"** — это gvfs-специфичная ошибка, при том что сама SMB-шара доступна и работает. Обычно значит, что не хватает именно **SMB-бэкенда gvfs** (сам `gio` может быть установлен как чужая зависимость, а конкретно SMB-плагин к нему — нет). Проверить и починить:
|
||||
```bash
|
||||
# проверить руками, минуя nsm
|
||||
gio mount smb://сервер/шара
|
||||
|
||||
# на ALT/Fedora-подобных — убедиться, что бэкенд установлен
|
||||
sudo apt-get install gvfs-backends # (или свой пакетный менеджер)
|
||||
|
||||
# проверить, что D-Bus сессия вообще есть (без неё gvfs не активируется)
|
||||
echo $DBUS_SESSION_BUS_ADDRESS
|
||||
```
|
||||
Если это не помогло — причина может быть глубже, в согласовании версии протокола SMB между клиентом и сервером (см. `journalctl` на предмет строк вида `smbXcli_negprot` от `gvfsd`); в этом случае поможет `client min protocol` в `/etc/samba/smb.conf`, как описано выше.
|
||||
|
||||
Для интернет-радио (**SomaFM**, **Radio Paradise** и т.д.) и MusicBrainz дополнительных системных зависимостей не нужно ни на одной ОС — TLS обеспечивает `rustls` (чистый Rust, без OpenSSL).
|
||||
|
||||
### Сборка проекта
|
||||
|
||||
```bash
|
||||
git clone <URL-этого-репозитория>
|
||||
cd nsm
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Бинарник появится в `target/release/nsm` (на Windows — `target\release\nsm.exe`).
|
||||
|
||||
### Запуск
|
||||
|
||||
```bash
|
||||
./target/release/nsm /путь/к/музыке
|
||||
```
|
||||
|
||||
Если путь не указан, по умолчанию используется `~/Music`.
|
||||
|
||||
## Управление
|
||||
|
||||
### Глобальные клавиши (любая вкладка)
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---|---|
|
||||
| `Space` | play/pause |
|
||||
| `N` / `P` | следующий / предыдущий трек |
|
||||
| `s` | shuffle вкл/выкл |
|
||||
| `r` | repeat (выкл → всё → один трек → выкл) |
|
||||
| `m` | mute |
|
||||
| `[` / `]` | скорость воспроизведения вниз/вверх |
|
||||
| `←` / `→` | перемотка −5с / +5с |
|
||||
| `e` | фокус на эквалайзер |
|
||||
| `h` | включить/выключить запись истории прослушиваний |
|
||||
| `v` | переключить обложка/спектр |
|
||||
| `z` | развернуть обложку/спектр на весь экран |
|
||||
| `F` | затухание в конце трека вкл/выкл |
|
||||
| `G` | выравнивание громкости вкл/выкл |
|
||||
| `I` | переключить протокол отображения обложки (если автоопределение ошиблось) |
|
||||
| `Tab` | следующая вкладка |
|
||||
| `q` | выход |
|
||||
|
||||
### Эквалайзер (после `e`)
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---|---|
|
||||
| `←` / `→` | выбор полосы |
|
||||
| `↑` / `↓` | ±1dB на выбранной полосе |
|
||||
| `0` | сброс текущей полосы |
|
||||
| `R` | сброс всех полос |
|
||||
| `Esc` / `e` | выйти из фокуса |
|
||||
|
||||
### Library
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---|---|
|
||||
| `j`/`k`, `↓`/`↑` | навигация |
|
||||
| `Enter` | раскрыть альбом / сыграть |
|
||||
| `Esc` | свернуть альбом |
|
||||
| `/` | поиск |
|
||||
| `a` | добавить в плейлист |
|
||||
| `c` | подключить SMB-шару |
|
||||
| `L` | добавить ещё одну папку к библиотеке |
|
||||
| `C` | очистить библиотеку (не трогает файлы на диске) |
|
||||
|
||||
### Playlists
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---|---|
|
||||
| `j`/`k` | навигация |
|
||||
| `Enter` | открыть плейлист / играть с этого места |
|
||||
| `n` | новый плейлист |
|
||||
| `J`/`K` | переместить трек вниз/вверх |
|
||||
| `d` | удалить трек из плейлиста |
|
||||
| `Esc` | назад к списку плейлистов |
|
||||
|
||||
### Queue
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---|---|
|
||||
| `j`/`k` | навигация |
|
||||
| `Enter` | играть с выбранного места |
|
||||
|
||||
### Radio
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---|---|
|
||||
| `j`/`k` | навигация |
|
||||
| `Enter` | играть станцию |
|
||||
| `u` | добавить свою станцию по URL |
|
||||
| `o` | поиск станций онлайн |
|
||||
| `d` | удалить (только свою) станцию |
|
||||
|
||||
### Stats
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---|---|
|
||||
| `p` | переключить период (день → неделя → месяц) |
|
||||
| `x` | сбросить историю (нужно нажать дважды подряд для подтверждения) |
|
||||
|
||||
### New Releases
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---|---|
|
||||
| `p` | переключить период (день → неделя → месяц) |
|
||||
| `f` | проверить новые релизы у всех артистов из библиотеки (может занять время, см. ниже) |
|
||||
| `a` | вручную проверить одного артиста по имени (не обязательно есть в библиотеке) — добавляется к уже показанному списку |
|
||||
|
||||
В полях ввода текста — `Enter` подтверждает, `Esc` отменяет, `Backspace` стирает.
|
||||
|
||||
## Архитектура
|
||||
|
||||
Cargo-workspace из четырёх крейтов:
|
||||
|
||||
```
|
||||
crates/
|
||||
├── player/ — движок воспроизведения: декодирование (symphonia), вывод звука (cpal),
|
||||
│ эквалайзер, ресемплинг для скорости, очередь, интернет-радио
|
||||
├── library/ — сканирование библиотеки, теги (lofty), cue-листы, плейлисты,
|
||||
│ радиостанции, SMB
|
||||
├── tui/ — интерфейс на ratatui: вкладки, обложка, эквалайзер, event loop
|
||||
└── nsm/ — тонкий бинарник, связывающий всё вместе
|
||||
```
|
||||
|
||||
`player` и `library` не знают друг о друге — `tui` связывает их через `player::QueueItem`.
|
||||
|
||||
## История прослушиваний
|
||||
|
||||
Хранится локально в `~/.config/nsm/history.jsonl` (одна запись — одна строка JSON: артист, трек, дата/время). Никуда не отправляется. При первом запуске спрашивается разрешение; в любой момент можно включить/выключить клавишей `h`, а на вкладке **Stats** — посмотреть диаграмму по часам/дням, список конкретных прослушанных треков и сбросить всё (двойным нажатием `x`).
|
||||
|
||||
## Кракозябры в старых тегах
|
||||
|
||||
Многие старые mp3 (особенно с тегами из 2000-х) хранят кириллицу в ID3v1 в кодировке CP1251, но она формально не объявлена — большинство библиотек (включая нашу) по умолчанию читают такие теги как Latin-1, получая "Ïåñíÿ" вместо "Песня". `nsm` определяет этот случай эвристически (по code points ≤0xFF и доле кириллических букв после перекодировки в CP1251) и чинит автоматически, не трогая уже нормальный UTF-8 текст.
|
||||
|
||||
## Обложка
|
||||
|
||||
Протокол отображения обложки (kitty/sixel/iTerm2/halfblocks) определяется автоматически по терминалу. Если автоопределение угадало неверно (например, показывает низкое разрешение на терминале, который на самом деле поддерживает kitty-графику) — жми `I`, чтобы вручную перебрать протоколы; выбор запоминается и применяется при следующих запусках.
|
||||
|
||||
## Новые релизы
|
||||
|
||||
Вкладка **New Releases** явно показывает релизы **только для артистов, уже имеющихся в твоей библиотеке** — это не глобальный чарт новинок (RYM активно блокирует ботов, и честно скрапить его не вышло — см. обсуждение). Вместо этого используется открытая база [MusicBrainz](https://musicbrainz.org), без ключей API.
|
||||
|
||||
Из-за ограничения MusicBrainz (не больше одного запроса в секунду) проверка большой библиотеки может занять время — по прогресс-бару видно, какой артист сейчас проверяется. Для очень плодовитых артистов (много синглов/переизданий за много лет) может понадобиться несколько запросов подряд на одного артиста — MusicBrainz не гарантирует сортировку результатов по дате, поэтому вычитывается вся дискография постранично, а не только первые 100 записей (иначе новый релиз мог случайно не попасть в выборку). Результаты кэшируются в `~/.config/nsm/new_releases_cache.json`, повторный запуск `nsm` сразу покажет последний результат без обращения к сети; обновление — вручную клавишей `f`.
|
||||
|
||||
**Важно понимать масштаб**: вкладка показывает релизы **только за последний день/неделю/месяц** (переключение `p`) — это буквально "что вышло недавно", а не вся дискография артиста. Если твой любимый артист не выпускал ничего последние 30 дней (для большинства артистов это норма), список для него будет пустым — это ожидаемое поведение, не баг.
|
||||
|
||||
## Выравнивание громкости
|
||||
|
||||
Клавиша `G`. Если в файле есть тег ReplayGain (`REPLAYGAIN_TRACK_GAIN` и аналоги — читается универсально через `lofty` для FLAC/MP3/OGG/MP4) — используется точное значение из тега. Если тега нет — включается адаптивный левеллер (AGC): он постоянно измеряет громкость последних 1-3 секунд и плавно подстраивает усиление к цели. Это **не то же самое**, что настоящий ReplayGain (посчитанный заранее по всему треку) — первые секунды трека выравниваются не идеально, дальше подстройка сходится.
|
||||
|
||||
## Известные ограничения
|
||||
|
||||
- Минимальный размер терминала — 70×18; на меньшем показывается предупреждение вместо сломанного интерфейса
|
||||
|
||||
- **Собиралось и тестировалось только на Linux** — сборки под macOS/Windows не проверялись; SMB-фича на них принципиально не будет работать (см. выше)
|
||||
|
||||
- **Opus не декодируется** (см. раздел «Форматы»)
|
||||
- **Плейлисты не хранят диапазоны cue-треков** — добавление cue-трека в плейлист сохраняет файл целиком, а не конкретный виртуальный трек
|
||||
- Подключение к SMB — блокирующий вызов в UI-потоке; интерфейс на секунду-другую подвиснет во время операции (поиск радиостанций и проверка новых релизов уже работают в фоне и не блокируют)
|
||||
- Мульти-файловые cue-листы (редкость) поддерживаются только по первому `FILE` в листе
|
||||
19
crates/library/Cargo.toml
Normal file
19
crates/library/Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "library"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
walkdir = "2"
|
||||
lofty = "0.19"
|
||||
nucleo-matcher = "0.3"
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||
encoding_rs = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
169
crates/library/src/cue.rs
Normal file
169
crates/library/src/cue.rs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CueTrack {
|
||||
pub number: u32,
|
||||
pub title: String,
|
||||
pub performer: Option<String>,
|
||||
/// Начало трека (INDEX 01), абсолютное время от начала физического файла.
|
||||
pub start: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CueSheet {
|
||||
pub album_title: Option<String>,
|
||||
pub album_performer: Option<String>,
|
||||
/// Абсолютный путь к аудиофайлу, на который ссылается cue (разрешён относительно
|
||||
/// расположения самого .cue файла).
|
||||
pub audio_path: PathBuf,
|
||||
pub tracks: Vec<CueTrack>,
|
||||
}
|
||||
|
||||
/// Парсит .cue файл. Поддерживается только один `FILE` на лист (типичный случай —
|
||||
/// весь альбом одним треком с cue-разметкой); мульти-файловые cue-листы — редкость,
|
||||
/// для них будет использован только первый встреченный `FILE`.
|
||||
pub fn parse_cue_file(cue_path: &Path) -> anyhow::Result<CueSheet> {
|
||||
let content = fs::read_to_string(cue_path)?;
|
||||
let dir = cue_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
|
||||
let mut album_title = None;
|
||||
let mut album_performer = None;
|
||||
let mut file_name: Option<String> = None;
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
let mut current_number: Option<u32> = None;
|
||||
let mut current_title: Option<String> = None;
|
||||
let mut current_performer: Option<String> = None;
|
||||
|
||||
for raw in content.lines() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let upper = line.to_ascii_uppercase();
|
||||
|
||||
if upper.starts_with("FILE ") {
|
||||
if file_name.is_none() {
|
||||
file_name = extract_quoted(line);
|
||||
}
|
||||
} else if let Some(rest) = upper.strip_prefix("TRACK ") {
|
||||
current_number = rest.split_whitespace().next().and_then(|s| s.parse().ok());
|
||||
current_title = None;
|
||||
current_performer = None;
|
||||
} else if upper.starts_with("TITLE ") {
|
||||
if current_number.is_some() {
|
||||
current_title = extract_quoted(line);
|
||||
} else {
|
||||
album_title = extract_quoted(line);
|
||||
}
|
||||
} else if upper.starts_with("PERFORMER ") {
|
||||
if current_number.is_some() {
|
||||
current_performer = extract_quoted(line);
|
||||
} else {
|
||||
album_performer = extract_quoted(line);
|
||||
}
|
||||
} else if let Some(rest) = upper.strip_prefix("INDEX 01 ") {
|
||||
if let (Some(num), Some(start)) = (current_number, parse_timecode(rest.trim())) {
|
||||
tracks.push(CueTrack {
|
||||
number: num,
|
||||
title: current_title
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("Track {num}")),
|
||||
performer: current_performer.clone(),
|
||||
start,
|
||||
});
|
||||
}
|
||||
}
|
||||
// INDEX 00 (пре-гэп), REM, CATALOG, FLAGS и т.п. — игнорируем, для базового
|
||||
// проигрывания не нужны.
|
||||
}
|
||||
|
||||
let file_name =
|
||||
file_name.ok_or_else(|| anyhow::anyhow!("cue sheet has no FILE entry: {}", cue_path.display()))?;
|
||||
if tracks.is_empty() {
|
||||
anyhow::bail!("cue sheet has no tracks with INDEX 01: {}", cue_path.display());
|
||||
}
|
||||
|
||||
Ok(CueSheet {
|
||||
album_title,
|
||||
album_performer,
|
||||
audio_path: dir.join(file_name),
|
||||
tracks,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_quoted(line: &str) -> Option<String> {
|
||||
let start = line.find('"')?;
|
||||
let rest = &line[start + 1..];
|
||||
let end = rest.find('"')?;
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
|
||||
/// Формат таймкода cue — `MM:SS:FF`, где FF — кадры, 75 кадров в секунде (стандарт CD).
|
||||
fn parse_timecode(s: &str) -> Option<Duration> {
|
||||
let parts: Vec<&str> = s.split(':').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let mm: u64 = parts[0].parse().ok()?;
|
||||
let ss: u64 = parts[1].parse().ok()?;
|
||||
let ff: u64 = parts[2].parse().ok()?;
|
||||
Some(Duration::from_secs_f64(
|
||||
(mm * 60 + ss) as f64 + ff as f64 / 75.0,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_basic_cue_sheet() {
|
||||
let cue = r#"
|
||||
PERFORMER "Test Artist"
|
||||
TITLE "Test Album"
|
||||
FILE "album.flac" WAVE
|
||||
TRACK 01 AUDIO
|
||||
TITLE "First Song"
|
||||
PERFORMER "Test Artist"
|
||||
INDEX 00 00:00:00
|
||||
INDEX 01 00:00:00
|
||||
TRACK 02 AUDIO
|
||||
TITLE "Second Song"
|
||||
INDEX 00 03:58:50
|
||||
INDEX 01 04:00:50
|
||||
"#;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cue_path = dir.path().join("album.cue");
|
||||
fs::write(&cue_path, cue).unwrap();
|
||||
|
||||
let sheet = parse_cue_file(&cue_path).unwrap();
|
||||
assert_eq!(sheet.album_title.as_deref(), Some("Test Album"));
|
||||
assert_eq!(sheet.album_performer.as_deref(), Some("Test Artist"));
|
||||
assert_eq!(sheet.audio_path, dir.path().join("album.flac"));
|
||||
assert_eq!(sheet.tracks.len(), 2);
|
||||
assert_eq!(sheet.tracks[0].title, "First Song");
|
||||
assert_eq!(sheet.tracks[0].start, Duration::ZERO);
|
||||
assert_eq!(sheet.tracks[1].title, "Second Song");
|
||||
// 4:00 + 50/75 s
|
||||
let expected = Duration::from_secs_f64(4.0 * 60.0 + 50.0 / 75.0);
|
||||
assert!((sheet.tracks[1].start.as_secs_f64() - expected.as_secs_f64()).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_without_title_gets_fallback_name() {
|
||||
let cue = r#"
|
||||
FILE "x.wav" WAVE
|
||||
TRACK 01 AUDIO
|
||||
INDEX 01 00:00:00
|
||||
"#;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cue_path = dir.path().join("x.cue");
|
||||
fs::write(&cue_path, cue).unwrap();
|
||||
|
||||
let sheet = parse_cue_file(&cue_path).unwrap();
|
||||
assert_eq!(sheet.tracks[0].title, "Track 1");
|
||||
}
|
||||
}
|
||||
251
crates/library/src/history.rs
Normal file
251
crates/library/src/history.rs
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{DateTime, Datelike, Duration as ChronoDuration, Local, Timelike};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HistoryEntry {
|
||||
pub artist: String,
|
||||
pub title: String,
|
||||
pub played_at: DateTime<Local>,
|
||||
}
|
||||
|
||||
/// Хранит историю прослушиваний в формате JSON Lines (одна запись на строку) — можно
|
||||
/// только дописывать в конец, не нужно перечитывать и переписывать весь файл на каждый
|
||||
/// новый трек.
|
||||
pub struct HistoryStore {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl HistoryStore {
|
||||
pub fn new(path: PathBuf) -> anyhow::Result<Self> {
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
}
|
||||
Ok(HistoryStore { path })
|
||||
}
|
||||
|
||||
pub fn record(&self, artist: &str, title: &str) -> anyhow::Result<()> {
|
||||
let entry = HistoryEntry {
|
||||
artist: artist.to_string(),
|
||||
title: title.to_string(),
|
||||
played_at: Local::now(),
|
||||
};
|
||||
let line = serde_json::to_string(&entry)?;
|
||||
let mut file = OpenOptions::new().create(true).append(true).open(&self.path)?;
|
||||
writeln!(file, "{line}")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Читает всю историю. Битые/повреждённые строки молча пропускаются, а не валят
|
||||
/// загрузку целиком.
|
||||
pub fn load_all(&self) -> anyhow::Result<Vec<HistoryEntry>> {
|
||||
if !self.path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let file = File::open(&self.path)?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut entries = Vec::new();
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entry) = serde_json::from_str::<HistoryEntry>(&line) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn clear(&self) -> anyhow::Result<()> {
|
||||
if self.path.exists() {
|
||||
fs::remove_file(&self.path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StatsPeriod {
|
||||
Day,
|
||||
Week,
|
||||
Month,
|
||||
}
|
||||
|
||||
impl StatsPeriod {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
StatsPeriod::Day => "Day",
|
||||
StatsPeriod::Week => "Week",
|
||||
StatsPeriod::Month => "Month",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
StatsPeriod::Day => StatsPeriod::Week,
|
||||
StatsPeriod::Week => StatsPeriod::Month,
|
||||
StatsPeriod::Month => StatsPeriod::Day,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Bucket {
|
||||
pub label: String,
|
||||
pub count: u64,
|
||||
}
|
||||
|
||||
/// Считает количество прослушанных треков по периоду:
|
||||
/// - Day — по часам за сегодня (24 корзины, 00..23)
|
||||
/// - Week — по дням за последние 7 дней (включая сегодня)
|
||||
/// - Month — по дням за последние 30 дней (включая сегодня)
|
||||
pub fn aggregate(entries: &[HistoryEntry], period: StatsPeriod) -> Vec<Bucket> {
|
||||
let now = Local::now();
|
||||
match period {
|
||||
StatsPeriod::Day => {
|
||||
let today = now.date_naive();
|
||||
let mut counts = [0u64; 24];
|
||||
for e in entries {
|
||||
if e.played_at.date_naive() == today {
|
||||
counts[e.played_at.hour() as usize] += 1;
|
||||
}
|
||||
}
|
||||
(0..24)
|
||||
.map(|h| Bucket {
|
||||
label: format!("{h:02}"),
|
||||
count: counts[h as usize],
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
StatsPeriod::Week => aggregate_days(entries, now, 7),
|
||||
StatsPeriod::Month => aggregate_days(entries, now, 30),
|
||||
}
|
||||
}
|
||||
|
||||
fn aggregate_days(entries: &[HistoryEntry], now: DateTime<Local>, days: i64) -> Vec<Bucket> {
|
||||
let today = now.date_naive();
|
||||
(0..days)
|
||||
.rev()
|
||||
.map(|i| {
|
||||
let day = today - ChronoDuration::days(i);
|
||||
let count = entries.iter().filter(|e| e.played_at.date_naive() == day).count() as u64;
|
||||
Bucket {
|
||||
label: format!("{:02}.{:02}", day.day(), day.month()),
|
||||
count,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Общее число прослушиваний за весь сохранённый период (без разбивки).
|
||||
pub fn total_count(entries: &[HistoryEntry]) -> usize {
|
||||
entries.len()
|
||||
}
|
||||
|
||||
/// Записи истории, попадающие в тот же период, что и `aggregate` (сегодня/7 дней/30 дней),
|
||||
/// отсортированные от самых новых к старым — чтобы видеть не только количество, но и что
|
||||
/// именно играло.
|
||||
pub fn entries_within_period(entries: &[HistoryEntry], period: StatsPeriod) -> Vec<&HistoryEntry> {
|
||||
let now = Local::now();
|
||||
let today = now.date_naive();
|
||||
let max_days_ago = match period {
|
||||
StatsPeriod::Day => 0,
|
||||
StatsPeriod::Week => 6,
|
||||
StatsPeriod::Month => 29,
|
||||
};
|
||||
let mut filtered: Vec<&HistoryEntry> = entries
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
let days_ago = (today - e.played_at.date_naive()).num_days();
|
||||
(0..=max_days_ago).contains(&days_ago)
|
||||
})
|
||||
.collect();
|
||||
filtered.sort_by(|a, b| b.played_at.cmp(&a.played_at));
|
||||
filtered
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry_at(artist: &str, title: &str, dt: DateTime<Local>) -> HistoryEntry {
|
||||
HistoryEntry {
|
||||
artist: artist.to_string(),
|
||||
title: title.to_string(),
|
||||
played_at: dt,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_and_load_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = HistoryStore::new(dir.path().join("history.jsonl")).unwrap();
|
||||
|
||||
store.record("Radiohead", "Karma Police").unwrap();
|
||||
store.record("Portishead", "Glory Box").unwrap();
|
||||
|
||||
let loaded = store.load_all().unwrap();
|
||||
assert_eq!(loaded.len(), 2);
|
||||
assert_eq!(loaded[0].artist, "Radiohead");
|
||||
assert_eq!(loaded[1].title, "Glory Box");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_removes_all_history() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = HistoryStore::new(dir.path().join("history.jsonl")).unwrap();
|
||||
store.record("A", "B").unwrap();
|
||||
store.clear().unwrap();
|
||||
assert!(store.load_all().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn day_aggregation_buckets_by_hour() {
|
||||
let now = Local::now();
|
||||
let entries = vec![
|
||||
entry_at("A", "1", now.with_hour(5).unwrap()),
|
||||
entry_at("A", "2", now.with_hour(5).unwrap()),
|
||||
entry_at("A", "3", now.with_hour(10).unwrap()),
|
||||
];
|
||||
let buckets = aggregate(&entries, StatsPeriod::Day);
|
||||
assert_eq!(buckets.len(), 24);
|
||||
assert_eq!(buckets[5].count, 2);
|
||||
assert_eq!(buckets[10].count, 1);
|
||||
assert_eq!(buckets[0].count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn week_aggregation_has_seven_buckets_ending_today() {
|
||||
let now = Local::now();
|
||||
let entries = vec![entry_at("A", "1", now)];
|
||||
let buckets = aggregate(&entries, StatsPeriod::Week);
|
||||
assert_eq!(buckets.len(), 7);
|
||||
// последняя корзина — сегодня, и наша единственная запись должна попасть в неё
|
||||
assert_eq!(buckets.last().unwrap().count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_aggregation_has_thirty_buckets() {
|
||||
let buckets = aggregate(&[], StatsPeriod::Month);
|
||||
assert_eq!(buckets.len(), 30);
|
||||
assert!(buckets.iter().all(|b| b.count == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_within_period_filters_and_sorts_newest_first() {
|
||||
let now = Local::now();
|
||||
let entries = vec![
|
||||
entry_at("Old", "1", now - ChronoDuration::days(40)),
|
||||
entry_at("Recent", "2", now - ChronoDuration::days(2)),
|
||||
entry_at("Today", "3", now),
|
||||
];
|
||||
let week = entries_within_period(&entries, StatsPeriod::Week);
|
||||
assert_eq!(week.len(), 2);
|
||||
assert_eq!(week[0].title, "3"); // самое новое первым
|
||||
assert_eq!(week[1].title, "2");
|
||||
}
|
||||
}
|
||||
271
crates/library/src/lib.rs
Normal file
271
crates/library/src/lib.rs
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
pub mod cue;
|
||||
pub mod history;
|
||||
pub mod model;
|
||||
pub mod new_releases;
|
||||
pub mod playlist;
|
||||
pub mod radio;
|
||||
mod scanner;
|
||||
mod search;
|
||||
pub mod settings;
|
||||
pub mod smb;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use model::{AlbumView, Track};
|
||||
pub use playlist::{Playlist, PlaylistStore};
|
||||
pub use radio::{RadioStore, Station};
|
||||
|
||||
/// В памяти хранит все найденные треки. Пересканировать можно в любой момент —
|
||||
/// например, по горячей клавише "refresh library" в UI.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Library {
|
||||
tracks: Vec<Track>,
|
||||
}
|
||||
|
||||
impl Library {
|
||||
pub fn scan(root: &Path) -> anyhow::Result<Self> {
|
||||
let tracks = scanner::scan(root)?;
|
||||
Ok(Library { tracks })
|
||||
}
|
||||
|
||||
pub fn rescan(&mut self, root: &Path) -> anyhow::Result<()> {
|
||||
self.tracks = scanner::scan(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Сканирует `root` и ДОБАВЛЯЕТ найденные треки к уже загруженным (в отличие от
|
||||
/// `rescan`, которая заменяет всё целиком). Используется для дополнительных источников —
|
||||
/// например, подключённой SMB-шары — поверх основной локальной библиотеки.
|
||||
/// Дедуплицирует по (путь, cue_start), чтобы повторное подключение той же шары
|
||||
/// не удваивало треки.
|
||||
pub fn add_root(&mut self, root: &Path) -> anyhow::Result<usize> {
|
||||
let new_tracks = scanner::scan(root)?;
|
||||
let existing: std::collections::HashSet<(PathBuf, Option<std::time::Duration>)> = self
|
||||
.tracks
|
||||
.iter()
|
||||
.map(|t| (t.path.clone(), t.cue_start))
|
||||
.collect();
|
||||
let mut added = 0;
|
||||
for t in new_tracks {
|
||||
let key = (t.path.clone(), t.cue_start);
|
||||
if !existing.contains(&key) {
|
||||
self.tracks.push(t);
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
Ok(added)
|
||||
}
|
||||
|
||||
/// Полностью очищает библиотеку (не трогает файлы на диске — только текущий сеанс).
|
||||
/// Удобно, если хочется временно послушать только один альбом: очистить, затем
|
||||
/// добавить нужную папку через `add_root`.
|
||||
pub fn clear(&mut self) {
|
||||
self.tracks.clear();
|
||||
}
|
||||
|
||||
pub fn tracks(&self) -> &[Track] {
|
||||
&self.tracks
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.tracks.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.tracks.is_empty()
|
||||
}
|
||||
|
||||
/// Нечёткий поиск по названию/исполнителю/альбому, отсортированный по релевантности.
|
||||
pub fn search(&self, query: &str) -> Vec<&Track> {
|
||||
search::search(&self.tracks, query)
|
||||
}
|
||||
|
||||
/// Группирует все треки по альбомам (ключ: album_artist/artist + album),
|
||||
/// треки внутри альбома отсортированы по диску и номеру трека.
|
||||
pub fn albums(&self) -> Vec<AlbumView<'_>> {
|
||||
let mut groups: BTreeMap<(String, String), Vec<&Track>> = BTreeMap::new();
|
||||
for track in &self.tracks {
|
||||
groups.entry(track.album_key()).or_default().push(track);
|
||||
}
|
||||
|
||||
let mut albums: Vec<AlbumView> = groups
|
||||
.into_iter()
|
||||
.map(|((artist, album), mut tracks)| {
|
||||
tracks.sort_by_key(|t| (t.disc_no.unwrap_or(0), t.track_no.unwrap_or(0), t.file_name()));
|
||||
AlbumView {
|
||||
artist,
|
||||
album,
|
||||
tracks,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
albums.sort_by(|a, b| a.artist.cmp(&b.artist).then(a.album.cmp(&b.album)));
|
||||
albums
|
||||
}
|
||||
|
||||
/// Все треки того же альбома, что и файл по данному пути, отсортированные
|
||||
/// по диску/номеру трека — используется для "открыть файл -> играть весь альбом".
|
||||
pub fn album_tracks_for_path(&self, path: &Path) -> Vec<&Track> {
|
||||
let Some(track) = self.tracks.iter().find(|t| t.path == path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let key = track.album_key();
|
||||
let mut result: Vec<&Track> = self
|
||||
.tracks
|
||||
.iter()
|
||||
.filter(|t| t.album_key() == key)
|
||||
.collect();
|
||||
result.sort_by_key(|t| (t.disc_no.unwrap_or(0), t.track_no.unwrap_or(0), t.file_name()));
|
||||
result
|
||||
}
|
||||
|
||||
pub fn track_by_path(&self, path: &Path) -> Option<&Track> {
|
||||
self.tracks.iter().find(|t| t.path == path)
|
||||
}
|
||||
|
||||
/// Уникальные имена исполнителей в библиотеке (album_artist в приоритете над artist,
|
||||
/// как и в группировке по альбомам), без "Unknown Artist" — используется для проверки
|
||||
/// новых релизов по уже имеющимся артистам.
|
||||
pub fn distinct_artists(&self) -> Vec<String> {
|
||||
let mut set = std::collections::BTreeSet::new();
|
||||
for t in &self.tracks {
|
||||
let name = t.album_artist.clone().unwrap_or_else(|| t.artist.clone());
|
||||
if !name.is_empty() && name != "Unknown Artist" {
|
||||
set.insert(name);
|
||||
}
|
||||
}
|
||||
set.into_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Обложка для конкретного трека — читается лениво по требованию (не хранится в памяти
|
||||
/// для всей библиотеки), пригодно для показа в "now playing" или при просмотре альбома.
|
||||
pub fn cover_for(path: &Path) -> Option<Vec<u8>> {
|
||||
scanner::read_cover(path)
|
||||
}
|
||||
|
||||
/// Путь по умолчанию для хранения плейлистов: `~/.config/nsm/playlists`.
|
||||
pub fn default_playlists_dir() -> PathBuf {
|
||||
dirs_config_dir().join("nsm").join("playlists")
|
||||
}
|
||||
|
||||
/// Путь по умолчанию для хранения пользовательских радиостанций: `~/.config/nsm`.
|
||||
pub fn default_radio_dir() -> PathBuf {
|
||||
dirs_config_dir().join("nsm")
|
||||
}
|
||||
|
||||
/// Путь по умолчанию для истории прослушиваний: `~/.config/nsm/history.jsonl`.
|
||||
pub fn default_history_path() -> PathBuf {
|
||||
dirs_config_dir().join("nsm").join("history.jsonl")
|
||||
}
|
||||
|
||||
/// Путь по умолчанию для настроек приложения: `~/.config/nsm/settings.json`.
|
||||
pub fn default_settings_path() -> PathBuf {
|
||||
dirs_config_dir().join("nsm").join("settings.json")
|
||||
}
|
||||
|
||||
/// Путь для кэша соответствий "артист -> MusicBrainz ID".
|
||||
pub fn default_mbid_cache_path() -> PathBuf {
|
||||
dirs_config_dir().join("nsm").join("mbid_cache.json")
|
||||
}
|
||||
|
||||
/// Путь для кэша последнего результата проверки новых релизов.
|
||||
pub fn default_new_releases_cache_path() -> PathBuf {
|
||||
dirs_config_dir().join("nsm").join("new_releases_cache.json")
|
||||
}
|
||||
|
||||
fn dirs_config_dir() -> PathBuf {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
fn track(path: &str, artist: &str, album: &str, track_no: Option<u32>) -> Track {
|
||||
Track {
|
||||
path: PathBuf::from(path),
|
||||
title: path.to_string(),
|
||||
artist: artist.to_string(),
|
||||
album: album.to_string(),
|
||||
album_artist: None,
|
||||
track_no,
|
||||
disc_no: None,
|
||||
year: None,
|
||||
genre: None,
|
||||
duration: Some(Duration::from_secs(200)),
|
||||
cue_start: None,
|
||||
cue_end: None,
|
||||
replay_gain_db: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn albums_group_and_sort_by_track_no() {
|
||||
let lib = Library {
|
||||
tracks: vec![
|
||||
track("/m/b/02.mp3", "Artist", "Album B", Some(2)),
|
||||
track("/m/b/01.mp3", "Artist", "Album B", Some(1)),
|
||||
track("/m/a/01.mp3", "Artist", "Album A", Some(1)),
|
||||
],
|
||||
};
|
||||
let albums = lib.albums();
|
||||
assert_eq!(albums.len(), 2);
|
||||
assert_eq!(albums[0].album, "Album A");
|
||||
assert_eq!(albums[1].album, "Album B");
|
||||
assert_eq!(albums[1].tracks[0].track_no, Some(1));
|
||||
assert_eq!(albums[1].tracks[1].track_no, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album_tracks_for_path_finds_siblings_sorted() {
|
||||
let lib = Library {
|
||||
tracks: vec![
|
||||
track("/m/b/02.mp3", "Artist", "Album B", Some(2)),
|
||||
track("/m/b/01.mp3", "Artist", "Album B", Some(1)),
|
||||
track("/m/a/01.mp3", "Artist", "Album A", Some(1)),
|
||||
],
|
||||
};
|
||||
let result = lib.album_tracks_for_path(Path::new("/m/b/02.mp3"));
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0].path, PathBuf::from("/m/b/01.mp3"));
|
||||
assert_eq!(result[1].path, PathBuf::from("/m/b/02.mp3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_finds_by_title_artist_album() {
|
||||
let lib = Library {
|
||||
tracks: vec![
|
||||
track("/m/x.mp3", "Radiohead", "OK Computer", Some(1)),
|
||||
track("/m/y.mp3", "Portishead", "Dummy", Some(1)),
|
||||
],
|
||||
};
|
||||
let results = lib.search("radio");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].artist, "Radiohead");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_root_does_not_duplicate_already_known_tracks() {
|
||||
let mut lib = Library {
|
||||
tracks: vec![track("/m/x.mp3", "Radiohead", "OK Computer", Some(1))],
|
||||
};
|
||||
// Симулируем повторное подключение того же источника: та же дорожка уже есть.
|
||||
let existing_count = lib.tracks.len();
|
||||
lib.tracks.push(track("/m/x.mp3", "Radiohead", "OK Computer", Some(1)));
|
||||
// Ручная проверка: без дедупликации было бы 2 одинаковых пути. Проверяем сам
|
||||
// механизм дедупликации отдельно на HashSet-логике, которую использует add_root.
|
||||
let unique: std::collections::HashSet<_> = lib
|
||||
.tracks
|
||||
.iter()
|
||||
.map(|t| (t.path.clone(), t.cue_start))
|
||||
.collect();
|
||||
assert_eq!(unique.len(), existing_count);
|
||||
}
|
||||
}
|
||||
63
crates/library/src/model.rs
Normal file
63
crates/library/src/model.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Track {
|
||||
pub path: PathBuf,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: String,
|
||||
pub album_artist: Option<String>,
|
||||
pub track_no: Option<u32>,
|
||||
pub disc_no: Option<u32>,
|
||||
pub year: Option<i32>,
|
||||
pub genre: Option<String>,
|
||||
pub duration: Option<Duration>,
|
||||
/// Если трек — часть cue-листа (несколько логических треков в одном физическом файле),
|
||||
/// здесь его диапазон внутри файла. `cue_end == None` означает "до конца файла"
|
||||
/// (последний трек на cue-листе).
|
||||
pub cue_start: Option<Duration>,
|
||||
pub cue_end: Option<Duration>,
|
||||
/// Значение из тега ReplayGain (REPLAYGAIN_TRACK_GAIN и аналоги в других форматах),
|
||||
/// в децибелах, если тег есть в файле — для выравнивания громкости между треками.
|
||||
pub replay_gain_db: Option<f32>,
|
||||
}
|
||||
|
||||
impl Track {
|
||||
/// Ключ группировки в альбом: (album_artist или artist, album).
|
||||
pub fn album_key(&self) -> (String, String) {
|
||||
let artist = self.album_artist.clone().unwrap_or_else(|| self.artist.clone());
|
||||
(artist, self.album.clone())
|
||||
}
|
||||
|
||||
pub fn file_name(&self) -> String {
|
||||
self.path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AlbumView<'a> {
|
||||
pub artist: String,
|
||||
pub album: String,
|
||||
pub tracks: Vec<&'a Track>,
|
||||
}
|
||||
|
||||
pub(crate) const AUDIO_EXTENSIONS: &[&str] = &["mp3", "flac", "ogg", "oga", "wav", "m4a", "aac"];
|
||||
|
||||
pub(crate) fn is_audio_file(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|ext| AUDIO_EXTENSIONS.contains(&ext.to_lowercase().as_str()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn is_cue_file(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|ext| ext.eq_ignore_ascii_case("cue"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
318
crates/library/src/new_releases.rs
Normal file
318
crates/library/src/new_releases.rs
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
use chrono::{DateTime, Local, NaiveDate};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const USER_AGENT: &str = "nsm-tui/0.1 ( local terminal music player, no public contact )";
|
||||
/// MusicBrainz просит не чаще одного запроса в секунду без специального разрешения.
|
||||
const RATE_LIMIT: StdDuration = StdDuration::from_millis(1100);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NewRelease {
|
||||
pub artist: String,
|
||||
pub title: String,
|
||||
pub release_date: NaiveDate,
|
||||
pub primary_type: String,
|
||||
}
|
||||
|
||||
/// Кэш соответствий "имя артиста -> MusicBrainz ID" (или None, если не нашли — чтобы не
|
||||
/// пытаться резолвить один и тот же непонятный тег артиста на каждом обновлении).
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct MbidCache {
|
||||
map: HashMap<String, Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ResultsCache {
|
||||
checked_at: DateTime<Local>,
|
||||
releases: Vec<NewRelease>,
|
||||
}
|
||||
|
||||
pub enum FetchProgress {
|
||||
Started { total: usize },
|
||||
ArtistChecked { index: usize, total: usize, artist: String },
|
||||
Finished {
|
||||
releases: Vec<NewRelease>,
|
||||
checked_at: DateTime<Local>,
|
||||
/// Сколько артистов не удалось проверить (сетевая ошибка) — если это число
|
||||
/// равно total, "0 новых релизов" скорее всего значит "нет сети", а не
|
||||
/// "релизов действительно нет".
|
||||
failed_artists: usize,
|
||||
total_artists: usize,
|
||||
},
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Загружает последний сохранённый результат (если есть), не обращаясь к сети —
|
||||
/// для отображения сразу при старте, пока пользователь не запросит обновление явно.
|
||||
pub fn load_cached(cache_path: &PathBuf) -> Option<(Vec<NewRelease>, DateTime<Local>)> {
|
||||
let data = fs::read_to_string(cache_path).ok()?;
|
||||
let cache: ResultsCache = serde_json::from_str(&data).ok()?;
|
||||
Some((cache.releases, cache.checked_at))
|
||||
}
|
||||
|
||||
/// Запускает фоновый поток, который резолвит артистов в MusicBrainz ID (с кэшированием)
|
||||
/// и вытягивает их релиз-группы, уважая rate-limit MusicBrainz (~1 запрос/сек). Прогресс и
|
||||
/// результат приходят через канал — вызывающая сторона (UI) опрашивает его по тику,
|
||||
/// не блокируясь.
|
||||
pub fn spawn_fetch(
|
||||
artists: Vec<String>,
|
||||
mbid_cache_path: PathBuf,
|
||||
results_cache_path: PathBuf,
|
||||
existing_releases: Vec<NewRelease>,
|
||||
) -> Receiver<FetchProgress> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut mbid_cache: MbidCache = fs::read_to_string(&mbid_cache_path)
|
||||
.ok()
|
||||
.and_then(|d| serde_json::from_str(&d).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let total = artists.len();
|
||||
let _ = tx.send(FetchProgress::Started { total });
|
||||
|
||||
let mut all_releases = existing_releases;
|
||||
let mut failed_artists = 0usize;
|
||||
|
||||
for (i, artist) in artists.iter().enumerate() {
|
||||
match resolve_and_fetch(artist, &mut mbid_cache) {
|
||||
Ok(mut releases) => all_releases.append(&mut releases),
|
||||
Err(_) => {
|
||||
// не удалось получить данные по конкретному артисту (сеть/не найден) —
|
||||
// пропускаем его, не прерывая весь процесс, но считаем для итоговой
|
||||
// сводки (чтобы отличить "нет новых релизов" от "нет сети")
|
||||
failed_artists += 1;
|
||||
}
|
||||
}
|
||||
let _ = tx.send(FetchProgress::ArtistChecked {
|
||||
index: i + 1,
|
||||
total,
|
||||
artist: artist.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Ok(data) = serde_json::to_string_pretty(&mbid_cache) {
|
||||
let _ = fs::write(&mbid_cache_path, data);
|
||||
}
|
||||
|
||||
// на случай, если объединяемый артист уже был проверен раньше — не дублируем записи
|
||||
all_releases.sort_by(|a, b| {
|
||||
(&a.artist, &a.title, a.release_date).cmp(&(&b.artist, &b.title, b.release_date))
|
||||
});
|
||||
all_releases.dedup_by(|a, b| a.artist == b.artist && a.title == b.title && a.release_date == b.release_date);
|
||||
|
||||
let checked_at = Local::now();
|
||||
let cache = ResultsCache {
|
||||
checked_at,
|
||||
releases: all_releases.clone(),
|
||||
};
|
||||
if let Ok(data) = serde_json::to_string_pretty(&cache) {
|
||||
let _ = fs::write(&results_cache_path, data);
|
||||
}
|
||||
|
||||
let _ = tx.send(FetchProgress::Finished {
|
||||
releases: all_releases,
|
||||
checked_at,
|
||||
failed_artists,
|
||||
total_artists: total,
|
||||
});
|
||||
});
|
||||
|
||||
rx
|
||||
}
|
||||
|
||||
fn resolve_and_fetch(artist: &str, mbid_cache: &mut MbidCache) -> anyhow::Result<Vec<NewRelease>> {
|
||||
let key = artist.to_lowercase();
|
||||
let mbid = if let Some(cached) = mbid_cache.map.get(&key) {
|
||||
cached.clone()
|
||||
} else {
|
||||
let resolved = resolve_artist_mbid(artist)?;
|
||||
std::thread::sleep(RATE_LIMIT);
|
||||
mbid_cache.map.insert(key, resolved.clone());
|
||||
resolved
|
||||
};
|
||||
|
||||
let Some(mbid) = mbid else {
|
||||
return Ok(Vec::new()); // артист не найден в MusicBrainz — не считаем это ошибкой
|
||||
};
|
||||
|
||||
let releases = fetch_release_groups(&mbid, artist)?;
|
||||
std::thread::sleep(RATE_LIMIT);
|
||||
Ok(releases)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ArtistSearchResponse {
|
||||
artists: Vec<ArtistSearchResult>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ArtistSearchResult {
|
||||
id: String,
|
||||
}
|
||||
|
||||
fn resolve_artist_mbid(artist: &str) -> anyhow::Result<Option<String>> {
|
||||
let response = ureq::get("https://musicbrainz.org/ws/2/artist")
|
||||
.set("User-Agent", USER_AGENT)
|
||||
.timeout(StdDuration::from_secs(10))
|
||||
.query("query", artist)
|
||||
.query("fmt", "json")
|
||||
.query("limit", "1")
|
||||
.call()?;
|
||||
let parsed: ArtistSearchResponse = response.into_json()?;
|
||||
Ok(parsed.artists.into_iter().next().map(|a| a.id))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReleaseGroupBrowseResponse {
|
||||
#[serde(rename = "release-group-count")]
|
||||
release_group_count: usize,
|
||||
#[serde(rename = "release-groups")]
|
||||
release_groups: Vec<ReleaseGroupJson>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ReleaseGroupJson {
|
||||
title: String,
|
||||
#[serde(rename = "first-release-date")]
|
||||
first_release_date: String,
|
||||
#[serde(rename = "primary-type")]
|
||||
primary_type: Option<String>,
|
||||
}
|
||||
|
||||
/// MusicBrainz НЕ гарантирует, что результаты browse-запроса отсортированы по дате —
|
||||
/// порядок "консистентный", но произвольный относительно времени релиза (см. документацию:
|
||||
/// "if you need to sort the entities, you will have to fetch all entities and sort them
|
||||
/// yourself"). Для плодовитого артиста (много синглов/концертников/переизданий за много
|
||||
/// лет) один запрос с limit=100 мог случайно не захватить самый свежий релиз — поэтому
|
||||
/// вычитываем все страницы, а не только первую.
|
||||
const MAX_PAGES_PER_ARTIST: usize = 10; // ограничение на случай совсем гигантской дискографии
|
||||
|
||||
fn fetch_release_groups(mbid: &str, artist_display_name: &str) -> anyhow::Result<Vec<NewRelease>> {
|
||||
let mut all_release_groups = Vec::new();
|
||||
let mut offset = 0usize;
|
||||
|
||||
for page in 0..MAX_PAGES_PER_ARTIST {
|
||||
if page > 0 {
|
||||
std::thread::sleep(RATE_LIMIT); // уважаем rate-limit MusicBrainz между страницами
|
||||
}
|
||||
let response = ureq::get("https://musicbrainz.org/ws/2/release-group")
|
||||
.set("User-Agent", USER_AGENT)
|
||||
.timeout(StdDuration::from_secs(10))
|
||||
.query("artist", mbid)
|
||||
.query("type", "album|ep|single")
|
||||
.query("fmt", "json")
|
||||
.query("limit", "100")
|
||||
.query("offset", &offset.to_string())
|
||||
.call()?;
|
||||
let parsed: ReleaseGroupBrowseResponse = response.into_json()?;
|
||||
|
||||
let got = parsed.release_groups.len();
|
||||
all_release_groups.extend(parsed.release_groups);
|
||||
offset += got;
|
||||
|
||||
if offset >= parsed.release_group_count || got == 0 {
|
||||
break; // выгребли всё
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_release_groups
|
||||
.into_iter()
|
||||
.filter_map(|rg| {
|
||||
let date = parse_partial_date(&rg.first_release_date)?;
|
||||
Some(NewRelease {
|
||||
artist: artist_display_name.to_string(),
|
||||
title: rg.title,
|
||||
release_date: date,
|
||||
primary_type: rg.primary_type.unwrap_or_else(|| "Release".to_string()),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// MusicBrainz отдаёт даты разной точности: `"2026"`, `"2026-07"` или `"2026-07-11"`.
|
||||
/// Для месяца/года берём первое число — этого достаточно, чтобы релиз попал в нужный
|
||||
/// период (день/неделя/месяц), точность до дня в этих случаях в самом MusicBrainz неизвестна.
|
||||
fn parse_partial_date(s: &str) -> Option<NaiveDate> {
|
||||
if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
|
||||
return Some(d);
|
||||
}
|
||||
if let Ok(d) = NaiveDate::parse_from_str(&format!("{s}-01"), "%Y-%m-%d") {
|
||||
return Some(d);
|
||||
}
|
||||
if let Ok(year) = s.parse::<i32>() {
|
||||
return NaiveDate::from_ymd_opt(year, 1, 1);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Фильтрует релизы, попадающие в последние `days` дней (включая сегодня), отсортированные
|
||||
/// от самых новых к старым.
|
||||
pub fn filter_within_days(releases: &[NewRelease], days: i64) -> Vec<NewRelease> {
|
||||
let today = Local::now().date_naive();
|
||||
let cutoff = today - chrono::Duration::days(days);
|
||||
let mut filtered: Vec<NewRelease> = releases
|
||||
.iter()
|
||||
.filter(|r| r.release_date > cutoff && r.release_date <= today)
|
||||
.cloned()
|
||||
.collect();
|
||||
filtered.sort_by(|a, b| b.release_date.cmp(&a.release_date));
|
||||
filtered
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_full_and_partial_dates() {
|
||||
assert_eq!(
|
||||
parse_partial_date("2026-07-11"),
|
||||
NaiveDate::from_ymd_opt(2026, 7, 11)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_partial_date("2026-07"),
|
||||
NaiveDate::from_ymd_opt(2026, 7, 1)
|
||||
);
|
||||
assert_eq!(parse_partial_date("2026"), NaiveDate::from_ymd_opt(2026, 1, 1));
|
||||
assert_eq!(parse_partial_date("garbage"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_and_sorts_by_recency() {
|
||||
let today = Local::now().date_naive();
|
||||
let releases = vec![
|
||||
NewRelease {
|
||||
artist: "A".into(),
|
||||
title: "Old".into(),
|
||||
release_date: today - chrono::Duration::days(40),
|
||||
primary_type: "Album".into(),
|
||||
},
|
||||
NewRelease {
|
||||
artist: "B".into(),
|
||||
title: "Recent".into(),
|
||||
release_date: today - chrono::Duration::days(2),
|
||||
primary_type: "Album".into(),
|
||||
},
|
||||
NewRelease {
|
||||
artist: "C".into(),
|
||||
title: "Today".into(),
|
||||
release_date: today,
|
||||
primary_type: "Single".into(),
|
||||
},
|
||||
];
|
||||
let week = filter_within_days(&releases, 7);
|
||||
assert_eq!(week.len(), 2);
|
||||
assert_eq!(week[0].title, "Today");
|
||||
assert_eq!(week[1].title, "Recent");
|
||||
|
||||
let month = filter_within_days(&releases, 30);
|
||||
assert_eq!(month.len(), 2); // "Old" (40 days) всё ещё за пределами месяца
|
||||
|
||||
let month_wide = filter_within_days(&releases, 45);
|
||||
assert_eq!(month_wide.len(), 3);
|
||||
}
|
||||
}
|
||||
126
crates/library/src/playlist.rs
Normal file
126
crates/library/src/playlist.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Playlist {
|
||||
pub name: String,
|
||||
pub tracks: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Playlist {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Playlist {
|
||||
name: name.into(),
|
||||
tracks: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Хранит несколько плейлистов, каждый — отдельный `.json` файл в `dir`.
|
||||
pub struct PlaylistStore {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl PlaylistStore {
|
||||
pub fn new(dir: PathBuf) -> anyhow::Result<Self> {
|
||||
fs::create_dir_all(&dir)?;
|
||||
Ok(PlaylistStore { dir })
|
||||
}
|
||||
|
||||
fn path_for(&self, name: &str) -> PathBuf {
|
||||
self.dir.join(format!("{}.json", sanitize(name)))
|
||||
}
|
||||
|
||||
pub fn list(&self) -> anyhow::Result<Vec<String>> {
|
||||
let mut names = Vec::new();
|
||||
for entry in fs::read_dir(&self.dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("json") {
|
||||
if let Ok(playlist) = self.load_from_path(&path) {
|
||||
names.push(playlist.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
names.sort();
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
pub fn load(&self, name: &str) -> anyhow::Result<Playlist> {
|
||||
self.load_from_path(&self.path_for(name))
|
||||
}
|
||||
|
||||
fn load_from_path(&self, path: &Path) -> anyhow::Result<Playlist> {
|
||||
let data = fs::read_to_string(path)?;
|
||||
Ok(serde_json::from_str(&data)?)
|
||||
}
|
||||
|
||||
pub fn save(&self, playlist: &Playlist) -> anyhow::Result<()> {
|
||||
let data = serde_json::to_string_pretty(playlist)?;
|
||||
fs::write(self.path_for(&playlist.name), data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete(&self, name: &str) -> anyhow::Result<()> {
|
||||
let path = self.path_for(name);
|
||||
if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create(&self, name: &str) -> anyhow::Result<Playlist> {
|
||||
let playlist = Playlist::new(name);
|
||||
self.save(&playlist)?;
|
||||
Ok(playlist)
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn create_save_load_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = PlaylistStore::new(dir.path().to_path_buf()).unwrap();
|
||||
|
||||
let mut pl = store.create("Chill Vibes").unwrap();
|
||||
pl.tracks.push(PathBuf::from("/music/a.mp3"));
|
||||
pl.tracks.push(PathBuf::from("/music/b.flac"));
|
||||
store.save(&pl).unwrap();
|
||||
|
||||
let loaded = store.load("Chill Vibes").unwrap();
|
||||
assert_eq!(loaded.name, "Chill Vibes");
|
||||
assert_eq!(loaded.tracks.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_returns_all_playlists() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = PlaylistStore::new(dir.path().to_path_buf()).unwrap();
|
||||
store.create("Workout").unwrap();
|
||||
store.create("Focus").unwrap();
|
||||
|
||||
let mut names = store.list().unwrap();
|
||||
names.sort();
|
||||
assert_eq!(names, vec!["Focus".to_string(), "Workout".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_removes_playlist() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = PlaylistStore::new(dir.path().to_path_buf()).unwrap();
|
||||
store.create("Temp").unwrap();
|
||||
store.delete("Temp").unwrap();
|
||||
assert!(store.load("Temp").is_err());
|
||||
}
|
||||
}
|
||||
207
crates/library/src/radio.rs
Normal file
207
crates/library/src/radio.rs
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Station {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Поле ответа Radio Browser API (https://api.radio-browser.info) — открытой, бесплатной
|
||||
/// базы интернет-радиостанций; используем только те поля, которые нужны для отображения
|
||||
/// и воспроизведения, остальные игнорируются serde автоматически.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiStation {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
url_resolved: String,
|
||||
#[serde(default)]
|
||||
country: String,
|
||||
#[serde(default)]
|
||||
tags: String,
|
||||
}
|
||||
|
||||
/// Ищет станции по названию через Radio Browser API — открытую базу из десятков тысяч
|
||||
/// интернет-радиостанций с прямыми ссылками на потоки. Возвращает вариант с уже готовым
|
||||
/// для проигрывания URL (url_resolved, если есть, иначе исходный url).
|
||||
/// Radio Browser рекомендует не хардкодить один сервер (мониторится через DNS-баланс
|
||||
/// `all.api.radio-browser.info`), потому что отдельные зеркала иногда отключаются насовсем —
|
||||
/// это реально случалось (де1 был отмечен как мёртвый в других проектах). Вместо полного
|
||||
/// DNS-discovery (нужна ещё и обратная DNS-резолюция для валидных TLS-имён) — короткий
|
||||
/// список известных живых зеркал с перебором при отказе.
|
||||
const RADIO_BROWSER_MIRRORS: &[&str] = &[
|
||||
"https://de2.api.radio-browser.info",
|
||||
"https://nl1.api.radio-browser.info",
|
||||
"https://at1.api.radio-browser.info",
|
||||
"https://fi1.api.radio-browser.info",
|
||||
];
|
||||
|
||||
pub fn search_online(query: &str) -> anyhow::Result<Vec<Station>> {
|
||||
let mut last_err = None;
|
||||
for base in RADIO_BROWSER_MIRRORS {
|
||||
match search_via(base, query) {
|
||||
Ok(stations) => return Ok(stations),
|
||||
Err(e) => last_err = Some(anyhow::anyhow!("{base}: {e}")),
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no radio-browser mirror responded")))
|
||||
}
|
||||
|
||||
fn search_via(base_url: &str, query: &str) -> anyhow::Result<Vec<Station>> {
|
||||
// 10с оказалось мало на практике: широкие запросы (много совпадений по подстроке,
|
||||
// например "europa") иногда отдаются медленно/по частям, и чтение обрывалось
|
||||
// на середине ответа. 25с — компромисс: разовое действие по явному нажатию
|
||||
// пользователя, не разбудет UI (выполняется в фоновом потоке).
|
||||
let response = ureq::get(&format!("{base_url}/json/stations/search"))
|
||||
.set("User-Agent", "nsm-tui/0.1 (terminal music player)")
|
||||
.timeout(std::time::Duration::from_secs(25))
|
||||
.query("name", query)
|
||||
.query("limit", "30")
|
||||
.query("hidebroken", "true")
|
||||
.query("order", "votes")
|
||||
.query("reverse", "true")
|
||||
.call()?;
|
||||
|
||||
let stations: Vec<ApiStation> = response.into_json()?;
|
||||
|
||||
Ok(stations
|
||||
.into_iter()
|
||||
.filter(|s| !s.url_resolved.is_empty() || !s.url.is_empty())
|
||||
.map(|s| {
|
||||
let url = if !s.url_resolved.is_empty() {
|
||||
s.url_resolved
|
||||
} else {
|
||||
s.url
|
||||
};
|
||||
let mut label = s.name;
|
||||
if !s.country.is_empty() {
|
||||
label = format!("{label} ({})", s.country);
|
||||
}
|
||||
if !s.tags.is_empty() {
|
||||
let first_tags: Vec<&str> = s.tags.split(',').take(2).collect();
|
||||
label = format!("{label} [{}]", first_tags.join(", "));
|
||||
}
|
||||
Station { name: label, url }
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Тот же поиск, что и `search_online`, но выполняется в фоновом потоке — не блокирует
|
||||
/// вызывающий (UI) поток на время сетевого запроса. Результат приходит через канал,
|
||||
/// опрашивать нужно неблокирующе (`try_recv`) из основного цикла отрисовки.
|
||||
pub fn spawn_search(query: String) -> Receiver<anyhow::Result<Vec<Station>>> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let result = search_online(&query);
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
/// Небольшой куратский список известных стабильных публичных стримов, чтобы список
|
||||
/// станций не был пустым сразу после установки. Пользователь может добавлять свои через UI.
|
||||
pub fn curated_stations() -> Vec<Station> {
|
||||
vec![
|
||||
Station {
|
||||
name: "SomaFM — Groove Salad".to_string(),
|
||||
url: "https://ice1.somafm.com/groovesalad-128-mp3".to_string(),
|
||||
},
|
||||
Station {
|
||||
name: "SomaFM — Drone Zone".to_string(),
|
||||
url: "https://ice1.somafm.com/dronezone-128-mp3".to_string(),
|
||||
},
|
||||
Station {
|
||||
name: "SomaFM — Deep Space One".to_string(),
|
||||
url: "https://ice1.somafm.com/deepspaceone-128-mp3".to_string(),
|
||||
},
|
||||
Station {
|
||||
name: "SomaFM — Space Station".to_string(),
|
||||
url: "https://ice1.somafm.com/spacestation-128-mp3".to_string(),
|
||||
},
|
||||
Station {
|
||||
name: "Radio Paradise — Main Mix".to_string(),
|
||||
url: "https://stream.radioparadise.com/mp3-192".to_string(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Хранит пользовательские станции в одном JSON-файле (список небольшой, отдельные
|
||||
/// файлы на каждую станцию, как для плейлистов, тут избыточны).
|
||||
pub struct RadioStore {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl RadioStore {
|
||||
pub fn new(dir: PathBuf) -> anyhow::Result<Self> {
|
||||
fs::create_dir_all(&dir)?;
|
||||
Ok(RadioStore {
|
||||
path: dir.join("stations.json"),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<Station> {
|
||||
fs::read_to_string(&self.path)
|
||||
.ok()
|
||||
.and_then(|data| serde_json::from_str(&data).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn add(&self, station: Station) -> anyhow::Result<()> {
|
||||
let mut stations = self.list();
|
||||
if !stations.iter().any(|s| s.url == station.url) {
|
||||
stations.push(station);
|
||||
self.save_all(&stations)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove(&self, url: &str) -> anyhow::Result<()> {
|
||||
let mut stations = self.list();
|
||||
stations.retain(|s| s.url != url);
|
||||
self.save_all(&stations)
|
||||
}
|
||||
|
||||
fn save_all(&self, stations: &[Station]) -> anyhow::Result<()> {
|
||||
let data = serde_json::to_string_pretty(stations)?;
|
||||
fs::write(&self.path, data)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn add_list_remove_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = RadioStore::new(dir.path().to_path_buf()).unwrap();
|
||||
|
||||
assert!(store.list().is_empty());
|
||||
|
||||
store
|
||||
.add(Station {
|
||||
name: "Test FM".to_string(),
|
||||
url: "https://example.com/stream".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(store.list().len(), 1);
|
||||
|
||||
// повторное добавление той же ссылки не дублирует запись
|
||||
store
|
||||
.add(Station {
|
||||
name: "Test FM Again".to_string(),
|
||||
url: "https://example.com/stream".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(store.list().len(), 1);
|
||||
|
||||
store.remove("https://example.com/stream").unwrap();
|
||||
assert!(store.list().is_empty());
|
||||
}
|
||||
}
|
||||
263
crates/library/src/scanner.rs
Normal file
263
crates/library/src/scanner.rs
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use lofty::file::{AudioFile, TaggedFileExt};
|
||||
use lofty::probe::Probe;
|
||||
use lofty::tag::Accessor;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::cue;
|
||||
use crate::model::{is_audio_file, is_cue_file, Track};
|
||||
|
||||
/// Рекурсивно обходит `root` и все вложенные папки. Сначала обрабатывает `.cue`-листы —
|
||||
/// каждый превращается в несколько "виртуальных" треков (диапазонов внутри одного
|
||||
/// физического файла); файл, на который ссылается успешно распарсенный cue, после этого
|
||||
/// не добавляется отдельно как обычный трек. Оставшиеся аудиофайлы читаются как обычно.
|
||||
/// Файлы, которые не удалось прочитать, пропускаются (не валят весь скан).
|
||||
pub fn scan(root: &Path) -> anyhow::Result<Vec<Track>> {
|
||||
let mut all_paths: Vec<PathBuf> = Vec::new();
|
||||
for entry in WalkDir::new(root)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
all_paths.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
let mut consumed_by_cue: HashSet<PathBuf> = HashSet::new();
|
||||
let mut tracks = Vec::new();
|
||||
|
||||
for cue_path in all_paths.iter().filter(|p| is_cue_file(p)) {
|
||||
let Ok(sheet) = cue::parse_cue_file(cue_path) else {
|
||||
continue; // битый/непонятный cue — пропускаем, не валим весь скан
|
||||
};
|
||||
if !sheet.audio_path.is_file() {
|
||||
continue; // cue ссылается на файл, которого нет (переименован/удалён)
|
||||
}
|
||||
consumed_by_cue.insert(sheet.audio_path.clone());
|
||||
|
||||
let file_duration = Probe::open(&sheet.audio_path)
|
||||
.ok()
|
||||
.and_then(|p| p.read().ok())
|
||||
.map(|f| f.properties().duration());
|
||||
let file_replay_gain = Probe::open(&sheet.audio_path)
|
||||
.ok()
|
||||
.and_then(|p| p.read().ok())
|
||||
.and_then(|f| {
|
||||
f.primary_tag()
|
||||
.or_else(|| f.first_tag())
|
||||
.and_then(|t| t.get_string(&lofty::tag::ItemKey::ReplayGainTrackGain))
|
||||
.and_then(parse_replay_gain_db)
|
||||
});
|
||||
|
||||
for (i, cue_track) in sheet.tracks.iter().enumerate() {
|
||||
let end = sheet.tracks.get(i + 1).map(|t| t.start);
|
||||
let duration = end
|
||||
.map(|e| e.saturating_sub(cue_track.start))
|
||||
.or_else(|| file_duration.map(|d| d.saturating_sub(cue_track.start)));
|
||||
|
||||
tracks.push(Track {
|
||||
path: sheet.audio_path.clone(),
|
||||
title: cue_track.title.clone(),
|
||||
artist: cue_track
|
||||
.performer
|
||||
.clone()
|
||||
.or_else(|| sheet.album_performer.clone())
|
||||
.unwrap_or_else(|| "Unknown Artist".to_string()),
|
||||
album: sheet
|
||||
.album_title
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Unknown Album".to_string()),
|
||||
album_artist: sheet.album_performer.clone(),
|
||||
track_no: Some(cue_track.number),
|
||||
disc_no: None,
|
||||
year: None,
|
||||
genre: None,
|
||||
duration,
|
||||
cue_start: Some(cue_track.start),
|
||||
cue_end: end,
|
||||
replay_gain_db: file_replay_gain,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for path in all_paths
|
||||
.iter()
|
||||
.filter(|p| is_audio_file(p) && !consumed_by_cue.contains(*p))
|
||||
{
|
||||
match read_track(path) {
|
||||
Ok(track) => tracks.push(track),
|
||||
Err(_) => continue, // повреждённый/нечитаемый файл — пропускаем
|
||||
}
|
||||
}
|
||||
|
||||
Ok(tracks)
|
||||
}
|
||||
|
||||
/// Читает теги одного файла в модель Track. Отсутствующие поля заполняются
|
||||
/// разумными значениями по умолчанию (имя файла как title, "Unknown Artist"/"Unknown Album").
|
||||
pub fn read_track(path: &Path) -> anyhow::Result<Track> {
|
||||
let tagged = Probe::open(path)?.read()?;
|
||||
let properties = tagged.properties();
|
||||
let duration = Some(properties.duration()).filter(|d| *d != Duration::ZERO);
|
||||
|
||||
let tag = tagged.primary_tag().or_else(|| tagged.first_tag());
|
||||
|
||||
let fallback_title = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
|
||||
let (title, artist, album, album_artist, track_no, disc_no, year, genre, replay_gain_db) = match tag
|
||||
{
|
||||
Some(tag) => (
|
||||
tag.title().map(|s| s.to_string()).unwrap_or(fallback_title),
|
||||
tag.artist()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "Unknown Artist".to_string()),
|
||||
tag.album()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "Unknown Album".to_string()),
|
||||
tag.get_string(&lofty::tag::ItemKey::AlbumArtist)
|
||||
.map(|s| s.to_string()),
|
||||
tag.track(),
|
||||
tag.disk(),
|
||||
tag.year().map(|y| y as i32),
|
||||
tag.genre().map(|s| s.to_string()),
|
||||
tag.get_string(&lofty::tag::ItemKey::ReplayGainTrackGain)
|
||||
.and_then(parse_replay_gain_db),
|
||||
),
|
||||
None => (
|
||||
fallback_title,
|
||||
"Unknown Artist".to_string(),
|
||||
"Unknown Album".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
};
|
||||
|
||||
Ok(Track {
|
||||
path: path.to_path_buf(),
|
||||
title: fix_mojibake(&title),
|
||||
artist: fix_mojibake(&artist),
|
||||
album: fix_mojibake(&album),
|
||||
album_artist: album_artist.map(|s| fix_mojibake(&s)),
|
||||
track_no,
|
||||
disc_no,
|
||||
year,
|
||||
genre: genre.map(|s| fix_mojibake(&s)),
|
||||
duration,
|
||||
cue_start: None,
|
||||
cue_end: None,
|
||||
replay_gain_db,
|
||||
})
|
||||
}
|
||||
|
||||
/// Парсит значение тега ReplayGain вида `"-6.50 dB"` или просто `"-6.50"` в число.
|
||||
fn parse_replay_gain_db(raw: &str) -> Option<f32> {
|
||||
raw.trim()
|
||||
.trim_end_matches("dB")
|
||||
.trim_end_matches("DB")
|
||||
.trim()
|
||||
.parse::<f32>()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Чинит "кракозябры" — частый случай со старыми (обычно ID3v1) тегами, где кириллица
|
||||
/// была записана в CP1251, а прочитана так, будто это Latin-1/ISO-8859-1 (типичное
|
||||
/// поведение множества старых тегеров и библиотек). Раз символы результата — это
|
||||
/// однозначно code points ≤0xFF (свойство Latin-1-декодирования), исходные байты
|
||||
/// восстанавливаются 1:1, и остаётся просто раскодировать их как CP1251. Если результат
|
||||
/// не выглядит правдоподобно кириллическим — возвращаем строку как есть, не трогаем.
|
||||
fn fix_mojibake(s: &str) -> String {
|
||||
if s.is_ascii() {
|
||||
return s.to_string();
|
||||
}
|
||||
let bytes: Option<Vec<u8>> = s
|
||||
.chars()
|
||||
.map(|c| {
|
||||
let cp = c as u32;
|
||||
if cp <= 0xFF {
|
||||
Some(cp as u8)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let Some(bytes) = bytes else {
|
||||
return s.to_string(); // содержит символы вне Latin-1 — это не наш случай, не трогаем
|
||||
};
|
||||
|
||||
let (decoded, _, had_errors) = encoding_rs::WINDOWS_1251.decode(&bytes);
|
||||
if had_errors {
|
||||
return s.to_string();
|
||||
}
|
||||
|
||||
let cyrillic_count = decoded
|
||||
.chars()
|
||||
.filter(|c| ('А'..='я').contains(c) || *c == 'Ё' || *c == 'ё')
|
||||
.count();
|
||||
let total_alpha = decoded.chars().filter(|c| c.is_alphabetic()).count().max(1);
|
||||
if cyrillic_count * 2 >= total_alpha {
|
||||
decoded.into_owned()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Достаёт обложку альбома (первую найденную картинку) для конкретного файла.
|
||||
/// Читается по требованию (не при сканировании), чтобы не раздувать память на всю библиотеку.
|
||||
pub fn read_cover(path: &Path) -> Option<Vec<u8>> {
|
||||
let tagged = Probe::open(path).ok()?.read().ok()?;
|
||||
let tag = tagged.primary_tag().or_else(|| tagged.first_tag())?;
|
||||
tag.pictures().first().map(|p| p.data().to_vec())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_replay_gain_with_db_suffix() {
|
||||
assert_eq!(parse_replay_gain_db("-6.50 dB"), Some(-6.50));
|
||||
assert_eq!(parse_replay_gain_db("-6.50dB"), Some(-6.50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_replay_gain_without_suffix() {
|
||||
assert_eq!(parse_replay_gain_db("3.20"), Some(3.20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_garbage() {
|
||||
assert_eq!(parse_replay_gain_db("not a number"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixes_cp1251_mojibake() {
|
||||
// "Ïåñíÿ" — это ровно то, что получается, когда CP1251-байты слова "Песня"
|
||||
// прочитаны как Latin-1 (типичное поведение старых ID3v1-тегов).
|
||||
assert_eq!(fix_mojibake("Ïåñíÿ"), "Песня");
|
||||
assert_eq!(fix_mojibake("Àðòèñò"), "Артист");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_plain_ascii_untouched() {
|
||||
assert_eq!(fix_mojibake("Hello World"), "Hello World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_already_correct_cyrillic_untouched() {
|
||||
// уже нормальный UTF-8 текст не должен портиться повторной перекодировкой
|
||||
assert_eq!(fix_mojibake("Пример"), "Пример");
|
||||
}
|
||||
}
|
||||
31
crates/library/src/search.rs
Normal file
31
crates/library/src/search.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
|
||||
use nucleo_matcher::{Config, Matcher};
|
||||
|
||||
use crate::model::Track;
|
||||
|
||||
/// Ищет треки по подстроке/нечёткому совпадению с title/artist/album, отсортированные
|
||||
/// по релевантности (лучшие совпадения первыми).
|
||||
pub fn search<'a>(tracks: &'a [Track], query: &str) -> Vec<&'a Track> {
|
||||
if query.trim().is_empty() {
|
||||
return tracks.iter().collect();
|
||||
}
|
||||
|
||||
let mut matcher = Matcher::new(Config::DEFAULT);
|
||||
let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
|
||||
|
||||
let mut scored: Vec<(u32, &Track)> = tracks
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
let haystack = format!("{} {} {}", t.title, t.artist, t.album);
|
||||
pattern
|
||||
.score(
|
||||
nucleo_matcher::Utf32Str::new(&haystack, &mut Vec::new()).slice(..),
|
||||
&mut matcher,
|
||||
)
|
||||
.map(|score| (score, t))
|
||||
})
|
||||
.collect();
|
||||
|
||||
scored.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
scored.into_iter().map(|(_, t)| t).collect()
|
||||
}
|
||||
77
crates/library/src/settings.rs
Normal file
77
crates/library/src/settings.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub history_enabled: bool,
|
||||
/// true, если пользователю уже задавали вопрос "запоминать ли прослушанное" —
|
||||
/// чтобы не спрашивать повторно при следующих запусках.
|
||||
pub first_run_done: bool,
|
||||
/// Явно выбранный пользователем протокол отображения обложки ("halfblocks"/"sixel"/
|
||||
/// "kitty"/"iterm2"), если автоопределение угадало неверно и он переключил вручную.
|
||||
/// `None` — использовать автоопределение при каждом запуске.
|
||||
#[serde(default)]
|
||||
pub cover_protocol: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings {
|
||||
history_enabled: false,
|
||||
first_run_done: false,
|
||||
cover_protocol: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Загружает настройки; если файла нет или он повреждён — считает это первым запуском
|
||||
/// (first_run_done = false), не падая с ошибкой.
|
||||
pub fn load(path: &PathBuf) -> Settings {
|
||||
fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|data| serde_json::from_str(&data).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &PathBuf) -> anyhow::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
}
|
||||
let data = serde_json::to_string_pretty(self)?;
|
||||
fs::write(path, data)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_file_is_treated_as_first_run() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("settings.json");
|
||||
let settings = Settings::load(&path);
|
||||
assert!(!settings.first_run_done);
|
||||
assert!(!settings.history_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("settings.json");
|
||||
let settings = Settings {
|
||||
history_enabled: true,
|
||||
first_run_done: true,
|
||||
cover_protocol: None,
|
||||
};
|
||||
settings.save(&path).unwrap();
|
||||
|
||||
let loaded = Settings::load(&path);
|
||||
assert!(loaded.history_enabled);
|
||||
assert!(loaded.first_run_done);
|
||||
}
|
||||
}
|
||||
175
crates/library/src/smb.rs
Normal file
175
crates/library/src/smb.rs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SmbShareInfo {
|
||||
pub server: String,
|
||||
pub share: String,
|
||||
pub username: Option<String>,
|
||||
/// Путь в локальной файловой системе, под которым шара доступна после монтирования
|
||||
/// (обычно `$XDG_RUNTIME_DIR/gvfs/smb-share:server=...,share=...`).
|
||||
pub mount_path: PathBuf,
|
||||
}
|
||||
|
||||
fn gvfs_base_dir() -> PathBuf {
|
||||
std::env::var("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("/run/user/1000"))
|
||||
.join("gvfs")
|
||||
}
|
||||
|
||||
/// Ищет уже смонтированную (например, через Nautilus/Dolphin или в прошлой сессии nsm)
|
||||
/// шару, не пытаясь монтировать заново — избегаем лишнего запроса пароля, если она и так
|
||||
/// доступна.
|
||||
pub fn find_existing_mount(server: &str, share: &str) -> Option<PathBuf> {
|
||||
find_mount_path(&gvfs_base_dir(), server, share).ok()
|
||||
}
|
||||
|
||||
fn find_mount_path(base: &Path, server: &str, share: &str) -> anyhow::Result<PathBuf> {
|
||||
let needle_server = format!("server={server}");
|
||||
let needle_share = format!("share={share}");
|
||||
let entries = std::fs::read_dir(base)?;
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if name.starts_with("smb-share:") && name.contains(&needle_server) && name.contains(&needle_share) {
|
||||
return Ok(entry.path());
|
||||
}
|
||||
}
|
||||
anyhow::bail!(
|
||||
"mounted share not found under {} (looked for server={server}, share={share})",
|
||||
base.display()
|
||||
)
|
||||
}
|
||||
|
||||
/// Монтирует SMB-шару через `gio mount` (использует системный gvfs — тот же механизм,
|
||||
/// которым пользуются Nautilus/Dolphin/GNOME Files). Требует установленного пакета gvfs
|
||||
/// (обычно `gvfs-backends` или аналог) в системе.
|
||||
pub fn mount_share(
|
||||
server: &str,
|
||||
share: &str,
|
||||
username: Option<&str>,
|
||||
password: Option<&str>,
|
||||
) -> anyhow::Result<SmbShareInfo> {
|
||||
let url = match username {
|
||||
Some(u) => format!("smb://{u}@{server}/{share}"),
|
||||
None => format!("smb://{server}/{share}"),
|
||||
};
|
||||
|
||||
let mut child = Command::new("gio")
|
||||
.arg("mount")
|
||||
.arg(&url)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("failed to run 'gio mount' — is gvfs installed? ({e})"))?;
|
||||
|
||||
// `gio mount` в интерактивном режиме может запросить сразу три поля по порядку:
|
||||
// "User [xxx]:", "Domain [xxx]:", "Password:" — даже если логин уже указан в URL,
|
||||
// некоторые версии всё равно переспрашивают. Раньше мы кормили в stdin только пароль,
|
||||
// из-за чего он мог попасть не в то поле. Теперь отвечаем на все три по очереди —
|
||||
// лишние строки, которые gio не запросит, просто останутся непрочитанными и не помешают.
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
let user_line = username.unwrap_or("guest");
|
||||
let _ = writeln!(stdin, "{user_line}"); // User
|
||||
let _ = writeln!(stdin); // Domain — пустая строка, принимаем предложенное по умолчанию
|
||||
let _ = writeln!(stdin, "{}", password.unwrap_or("")); // Password
|
||||
drop(stdin);
|
||||
}
|
||||
|
||||
// std::process::Child не умеет ждать с таймаутом сам по себе — если сеть недоступна
|
||||
// и gio просто зависнет (не вернёт управление), это заморозило бы весь UI-поток
|
||||
// навсегда. Поэтому ждём результат в отдельном потоке и даём ему не больше 30 секунд;
|
||||
// если не успели — принудительно убиваем процесс по PID.
|
||||
const MOUNT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
let pid = child.id();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(child.wait_with_output());
|
||||
});
|
||||
|
||||
let output = match rx.recv_timeout(MOUNT_TIMEOUT) {
|
||||
Ok(result) => result?,
|
||||
Err(_) => {
|
||||
let _ = Command::new("kill").arg("-9").arg(pid.to_string()).status();
|
||||
anyhow::bail!(
|
||||
"gio mount timed out after {}s (network or gvfs unresponsive?)",
|
||||
MOUNT_TIMEOUT.as_secs()
|
||||
);
|
||||
}
|
||||
};
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("gio mount failed: {}", stderr.trim());
|
||||
}
|
||||
|
||||
let mount_path = find_mount_path(&gvfs_base_dir(), server, share)?;
|
||||
Ok(SmbShareInfo {
|
||||
server: server.to_string(),
|
||||
share: share.to_string(),
|
||||
username: username.map(|s| s.to_string()),
|
||||
mount_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Отмонтирует шару (симметрично `mount_share`). Та же защита от зависания, что и в
|
||||
/// `mount_share` — не блокируем вызывающий поток дольше отведённого времени.
|
||||
pub fn unmount_share(server: &str, share: &str) -> anyhow::Result<()> {
|
||||
let url = format!("smb://{server}/{share}");
|
||||
const UNMOUNT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
||||
|
||||
let mut child = Command::new("gio")
|
||||
.arg("mount")
|
||||
.arg("-u")
|
||||
.arg(&url)
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("failed to run 'gio mount -u' ({e})"))?;
|
||||
let pid = child.id();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(child.wait());
|
||||
});
|
||||
|
||||
let status = match rx.recv_timeout(UNMOUNT_TIMEOUT) {
|
||||
Ok(result) => result?,
|
||||
Err(_) => {
|
||||
let _ = Command::new("kill").arg("-9").arg(pid.to_string()).status();
|
||||
anyhow::bail!(
|
||||
"gio mount -u timed out after {}s",
|
||||
UNMOUNT_TIMEOUT.as_secs()
|
||||
);
|
||||
}
|
||||
};
|
||||
if !status.success() {
|
||||
anyhow::bail!("gio mount -u failed for {url}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn finds_matching_mount_dir_by_server_and_share() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let gvfs = dir.path().join("gvfs");
|
||||
std::fs::create_dir_all(&gvfs).unwrap();
|
||||
std::fs::create_dir(gvfs.join("smb-share:server=nas.local,share=music")).unwrap();
|
||||
std::fs::create_dir(gvfs.join("smb-share:server=other.local,share=docs")).unwrap();
|
||||
|
||||
let found = find_mount_path(&gvfs, "nas.local", "music").unwrap();
|
||||
assert_eq!(found, gvfs.join("smb-share:server=nas.local,share=music"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_error_when_no_matching_mount() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let gvfs = dir.path().join("gvfs");
|
||||
std::fs::create_dir_all(&gvfs).unwrap();
|
||||
|
||||
assert!(find_mount_path(&gvfs, "nas.local", "music").is_err());
|
||||
}
|
||||
}
|
||||
12
crates/nsm/Cargo.toml
Normal file
12
crates/nsm/Cargo.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[package]
|
||||
name = "nsm"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "nsm"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
tui = { path = "../tui" }
|
||||
15
crates/nsm/src/main.rs
Normal file
15
crates/nsm/src/main.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let root = std::env::args()
|
||||
.nth(1)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_music_dir);
|
||||
tui::run(root)
|
||||
}
|
||||
|
||||
fn default_music_dir() -> PathBuf {
|
||||
std::env::var_os("HOME")
|
||||
.map(|h| PathBuf::from(h).join("Music"))
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
17
crates/player/Cargo.toml
Normal file
17
crates/player/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "player"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
symphonia = { version = "0.5", features = ["all"] }
|
||||
cpal = "0.15"
|
||||
rubato = "0.15"
|
||||
rand = "0.8"
|
||||
ringbuf = "0.3"
|
||||
ureq = "2"
|
||||
realfft = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
389
crates/player/src/decode.rs
Normal file
389
crates/player/src/decode.rs
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::time::Duration;
|
||||
|
||||
use symphonia::core::audio::{SampleBuffer, SignalSpec};
|
||||
use symphonia::core::codecs::{Decoder, DecoderOptions};
|
||||
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo};
|
||||
use symphonia::core::io::{MediaSourceStream, ReadOnlySource};
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
use symphonia::core::units::Time;
|
||||
|
||||
use crate::types::{PlayerEvent, TrackInfo};
|
||||
|
||||
pub struct DecodedTrack {
|
||||
pub format: Box<dyn FormatReader>,
|
||||
pub decoder: Box<dyn Decoder>,
|
||||
pub track_id: u32,
|
||||
pub spec: SignalSpec,
|
||||
pub duration: Option<Duration>,
|
||||
}
|
||||
|
||||
fn build_decoded(mss: MediaSourceStream, hint: Hint) -> anyhow::Result<DecodedTrack> {
|
||||
let probed = symphonia::default::get_probe().format(
|
||||
&hint,
|
||||
mss,
|
||||
&FormatOptions::default(),
|
||||
&MetadataOptions::default(),
|
||||
)?;
|
||||
|
||||
let format = probed.format;
|
||||
let track = format
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL)
|
||||
.ok_or_else(|| anyhow::anyhow!("no playable track found"))?;
|
||||
|
||||
let track_id = track.id;
|
||||
let decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())?;
|
||||
|
||||
let sample_rate = track
|
||||
.codec_params
|
||||
.sample_rate
|
||||
.ok_or_else(|| anyhow::anyhow!("unknown sample rate"))?;
|
||||
let channels = track
|
||||
.codec_params
|
||||
.channels
|
||||
.ok_or_else(|| anyhow::anyhow!("unknown channel layout"))?;
|
||||
|
||||
let duration = track
|
||||
.codec_params
|
||||
.n_frames
|
||||
.map(|frames| Duration::from_secs_f64(frames as f64 / sample_rate as f64));
|
||||
|
||||
let spec = SignalSpec::new(sample_rate, channels);
|
||||
|
||||
Ok(DecodedTrack {
|
||||
format,
|
||||
decoder,
|
||||
track_id,
|
||||
spec,
|
||||
duration,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn open_track(path: &Path) -> anyhow::Result<DecodedTrack> {
|
||||
let file = File::open(path)?;
|
||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
hint.with_extension(ext);
|
||||
}
|
||||
|
||||
build_decoded(mss, hint)
|
||||
}
|
||||
|
||||
/// Открывает интернет-радио поток по URL. Поток не поддерживает seek — оборачиваем его
|
||||
/// через `ReadOnlySource`, symphonia корректно работает и с несикаемыми источниками
|
||||
/// (типичный случай для live-стримов).
|
||||
/// Читает поток, прозрачно вырезая из него ICY-метаданные (каждые `metaint` байт аудио
|
||||
/// сервер вставляет блок с текстовым описанием текущего трека) и отправляя распарсенный
|
||||
/// заголовок трека через `evt_tx`. Если `metaint == 0`, работает как простой passthrough.
|
||||
struct IcyStream<R> {
|
||||
inner: R,
|
||||
metaint: usize,
|
||||
bytes_since_meta: usize,
|
||||
evt_tx: Sender<PlayerEvent>,
|
||||
last_title: Option<String>,
|
||||
}
|
||||
|
||||
impl<R: Read> Read for IcyStream<R> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
if self.metaint == 0 {
|
||||
return self.inner.read(buf);
|
||||
}
|
||||
let remaining_before_meta = self.metaint - self.bytes_since_meta;
|
||||
let to_read = buf.len().min(remaining_before_meta).max(1);
|
||||
let n = self.inner.read(&mut buf[..to_read])?;
|
||||
self.bytes_since_meta += n;
|
||||
if self.bytes_since_meta >= self.metaint && n > 0 {
|
||||
self.consume_metadata()?;
|
||||
self.bytes_since_meta = 0;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Read> IcyStream<R> {
|
||||
fn consume_metadata(&mut self) -> std::io::Result<()> {
|
||||
let mut len_byte = [0u8; 1];
|
||||
self.inner.read_exact(&mut len_byte)?;
|
||||
let len = len_byte[0] as usize * 16;
|
||||
if len == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let mut meta_buf = vec![0u8; len];
|
||||
self.inner.read_exact(&mut meta_buf)?;
|
||||
let text = String::from_utf8_lossy(&meta_buf);
|
||||
let text = text.trim_matches(char::from(0));
|
||||
if let Some(title) = parse_stream_title(text) {
|
||||
if self.last_title.as_deref() != Some(title.as_str()) {
|
||||
let _ = self.evt_tx.send(PlayerEvent::RadioMetadata(title.clone()));
|
||||
self.last_title = Some(title);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Извлекает значение `StreamTitle='...'` из блока ICY-метаданных вида
|
||||
/// `StreamTitle='Artist - Song';StreamUrl='...';`.
|
||||
fn parse_stream_title(meta: &str) -> Option<String> {
|
||||
let key = "StreamTitle='";
|
||||
let start = meta.find(key)? + key.len();
|
||||
let rest = &meta[start..];
|
||||
let end = rest.find("';")?;
|
||||
let title = rest[..end].trim();
|
||||
if title.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(title.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Открывает интернет-радио поток по URL. Поток не поддерживает seek — оборачиваем его
|
||||
/// через `ReadOnlySource`, symphonia корректно работает и с несикаемыми источниками
|
||||
/// (типичный случай для live-стрима). Запрашивает ICY-метаданные (Icy-MetaData: 1) и
|
||||
/// прозрачно вырезает их из аудио-потока, публикуя название трека через `evt_tx`.
|
||||
pub fn open_url(url: &str, evt_tx: Sender<PlayerEvent>) -> anyhow::Result<DecodedTrack> {
|
||||
// ВАЖНО: используем timeout_connect/timeout_read через отдельный Agent, а не общий
|
||||
// .timeout() на запросе — общий таймаут ureq считает от начала запроса ДО КОНЦА чтения
|
||||
// тела, что оборвало бы живой радио-поток ровно через N секунд. timeout_read же — это
|
||||
// таймаут между отдельными чтениями сокета: ловит реально зависший/замолчавший сервер,
|
||||
// не убивая нормально играющее радио.
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout_connect(Duration::from_secs(10))
|
||||
.timeout_read(Duration::from_secs(30))
|
||||
.build();
|
||||
let response = agent.get(url).set("Icy-MetaData", "1").call()?;
|
||||
|
||||
let content_type = response.header("Content-Type").map(|s| s.to_string());
|
||||
let metaint: usize = response
|
||||
.header("icy-metaint")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let reader = response.into_reader();
|
||||
let icy_reader = IcyStream {
|
||||
inner: reader,
|
||||
metaint,
|
||||
bytes_since_meta: 0,
|
||||
evt_tx,
|
||||
last_title: None,
|
||||
};
|
||||
let source = ReadOnlySource::new(icy_reader);
|
||||
let mss = MediaSourceStream::new(Box::new(source), Default::default());
|
||||
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = guess_extension_from_content_type(content_type.as_deref()) {
|
||||
hint.with_extension(ext);
|
||||
}
|
||||
|
||||
build_decoded(mss, hint)
|
||||
}
|
||||
|
||||
fn guess_extension_from_content_type(ct: Option<&str>) -> Option<&'static str> {
|
||||
match ct?.split(';').next()?.trim() {
|
||||
"audio/mpeg" | "audio/mp3" => Some("mp3"),
|
||||
"audio/aac" | "audio/aacp" => Some("aac"),
|
||||
"application/ogg" | "audio/ogg" => Some("ogg"),
|
||||
"audio/flac" => Some("flac"),
|
||||
"audio/wav" | "audio/x-wav" => Some("wav"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Прыгает на указанную позицию внутри уже открытого трека (не работает для радио —
|
||||
/// сетевой поток несикаемый). Сбрасывает состояние декодера после прыжка, как того требует
|
||||
/// symphonia, и возвращает фактическую позицию (seek может быть неточным для некоторых
|
||||
/// форматов — используем `Accurate`, что даёт позицию не позже запрошенной).
|
||||
pub fn seek(track: &mut DecodedTrack, position: Duration) -> anyhow::Result<Duration> {
|
||||
let time = Time {
|
||||
seconds: position.as_secs(),
|
||||
frac: position.subsec_nanos() as f64 / 1_000_000_000.0,
|
||||
};
|
||||
let seeked = track.format.seek(
|
||||
SeekMode::Accurate,
|
||||
SeekTo::Time {
|
||||
time,
|
||||
track_id: Some(track.track_id),
|
||||
},
|
||||
)?;
|
||||
track.decoder.reset();
|
||||
Ok(Duration::from_secs_f64(
|
||||
seeked.actual_ts as f64 / track.spec.rate as f64,
|
||||
))
|
||||
}
|
||||
|
||||
/// Читает следующий декодированный пакет как interleaved f32. Возвращает None на EOF.
|
||||
pub fn next_packet_samples(track: &mut DecodedTrack) -> anyhow::Result<Option<Vec<f32>>> {
|
||||
loop {
|
||||
let packet = match track.format.next_packet() {
|
||||
Ok(p) => p,
|
||||
Err(symphonia::core::errors::Error::IoError(e))
|
||||
if e.kind() == std::io::ErrorKind::UnexpectedEof =>
|
||||
{
|
||||
return Ok(None)
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
if packet.track_id() != track.track_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
match track.decoder.decode(&packet) {
|
||||
Ok(decoded) => {
|
||||
let spec = *decoded.spec();
|
||||
let mut sample_buf = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
|
||||
sample_buf.copy_interleaved_ref(decoded);
|
||||
return Ok(Some(sample_buf.samples().to_vec()));
|
||||
}
|
||||
Err(symphonia::core::errors::Error::DecodeError(_)) => continue,
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Читает базовые теги (title/artist/album) и обложку, если есть. Не открывает поток для декодирования.
|
||||
pub fn read_tags(path: &Path) -> anyhow::Result<TrackInfo> {
|
||||
use symphonia::core::meta::StandardTagKey;
|
||||
|
||||
let file = File::open(path)?;
|
||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
hint.with_extension(ext);
|
||||
}
|
||||
|
||||
let mut probed = symphonia::default::get_probe().format(
|
||||
&hint,
|
||||
mss,
|
||||
&FormatOptions::default(),
|
||||
&MetadataOptions::default(),
|
||||
)?;
|
||||
|
||||
let mut title = None;
|
||||
let mut artist = None;
|
||||
let mut album = None;
|
||||
let mut cover = None;
|
||||
|
||||
let mut visit = |metadata: &symphonia::core::meta::MetadataRevision| {
|
||||
for tag in metadata.tags() {
|
||||
match tag.std_key {
|
||||
Some(StandardTagKey::TrackTitle) => title = Some(tag.value.to_string()),
|
||||
Some(StandardTagKey::Artist) => artist = Some(tag.value.to_string()),
|
||||
Some(StandardTagKey::Album) => album = Some(tag.value.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if cover.is_none() {
|
||||
if let Some(visual) = metadata.visuals().first() {
|
||||
cover = Some(visual.data.to_vec());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(metadata) = probed.format.metadata().current() {
|
||||
visit(metadata);
|
||||
}
|
||||
if let Some(mut metadata) = probed.metadata.get() {
|
||||
if let Some(rev) = metadata.skip_to_latest() {
|
||||
visit(rev);
|
||||
}
|
||||
}
|
||||
|
||||
let fallback_title = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
|
||||
let track = probed.format.tracks().first();
|
||||
let duration = track.and_then(|t| {
|
||||
let rate = t.codec_params.sample_rate?;
|
||||
let frames = t.codec_params.n_frames?;
|
||||
Some(Duration::from_secs_f64(frames as f64 / rate as f64))
|
||||
});
|
||||
|
||||
Ok(TrackInfo {
|
||||
path: path.to_path_buf(),
|
||||
title: title.unwrap_or(fallback_title),
|
||||
artist: artist.unwrap_or_else(|| "Unknown Artist".to_string()),
|
||||
album: album.unwrap_or_else(|| "Unknown Album".to_string()),
|
||||
duration,
|
||||
cover,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_stream_title() {
|
||||
let meta = "StreamTitle='Pink Floyd - Money';StreamUrl='http://example.com';";
|
||||
assert_eq!(
|
||||
parse_stream_title(meta),
|
||||
Some("Pink Floyd - Money".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_empty_title() {
|
||||
let meta = "StreamTitle='';StreamUrl='';";
|
||||
assert_eq!(parse_stream_title(meta), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_no_stream_title_key() {
|
||||
let meta = "SomeOtherField='value';";
|
||||
assert_eq!(parse_stream_title(meta), None);
|
||||
}
|
||||
|
||||
/// Ручная проверка реального seek на настоящем файле. Игнорируется по умолчанию
|
||||
/// (нужен ffmpeg в PATH для генерации фикстуры) — запускать явно: `cargo test -- --ignored`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn seek_jumps_to_requested_position() {
|
||||
let dir = std::env::temp_dir();
|
||||
let path = dir.join("nsm_seek_test.mp3");
|
||||
let status = std::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:duration=5",
|
||||
"-c:a",
|
||||
"libmp3lame",
|
||||
path.to_str().unwrap(),
|
||||
"-loglevel",
|
||||
"error",
|
||||
])
|
||||
.status()
|
||||
.expect("ffmpeg must be installed for this test");
|
||||
assert!(status.success());
|
||||
|
||||
let mut track = open_track(&path).expect("should open test mp3");
|
||||
let target = Duration::from_secs(3);
|
||||
let actual = seek(&mut track, target).expect("seek should succeed");
|
||||
|
||||
// Accurate-seek гарантирует позицию не позже запрошенной, но достаточно близкую.
|
||||
assert!(
|
||||
actual <= target && actual.as_secs_f64() > target.as_secs_f64() - 0.5,
|
||||
"expected actual seek position close to {target:?}, got {actual:?}"
|
||||
);
|
||||
|
||||
// После seek декодирование должно продолжаться без ошибок.
|
||||
let next = next_packet_samples(&mut track).expect("decode after seek should work");
|
||||
assert!(next.is_some(), "expected audio data after seeking");
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
717
crates/player/src/engine.rs
Normal file
717
crates/player/src/engine.rs
Normal file
|
|
@ -0,0 +1,717 @@
|
|||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use ringbuf::{HeapConsumer, HeapProducer, HeapRb};
|
||||
|
||||
use crate::decode::{next_packet_samples, open_track, open_url, read_tags, seek as decode_seek, DecodedTrack};
|
||||
use crate::eq::Equalizer;
|
||||
use crate::leveler::Leveler;
|
||||
use crate::queue::Queue;
|
||||
use crate::resample::SpeedResampler;
|
||||
use crate::spectrum::{SpectrumAnalyzer, FFT_SIZE};
|
||||
use crate::types::{OpenMode, PlaybackStatus, PlayerCommand, PlayerEvent, QueueItem, TrackInfo, EQ_BANDS};
|
||||
|
||||
/// Не чаще раза в этот интервал шлём событие со спектром — иначе можно засыпать UI-канал
|
||||
/// событиями чаще, чем это осмысленно для визуализации на экране.
|
||||
const SPECTRUM_SEND_INTERVAL: Duration = Duration::from_millis(60);
|
||||
|
||||
/// Точка входа: запускает движок в отдельном потоке и возвращает канал команд + канал событий.
|
||||
pub fn spawn() -> (Sender<PlayerCommand>, Receiver<PlayerEvent>) {
|
||||
let (cmd_tx, cmd_rx) = std::sync::mpsc::channel::<PlayerCommand>();
|
||||
let (evt_tx, evt_rx) = std::sync::mpsc::channel::<PlayerEvent>();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = run(cmd_rx, evt_tx.clone()) {
|
||||
let _ = evt_tx.send(PlayerEvent::Error(format!("engine crashed: {e}")));
|
||||
}
|
||||
});
|
||||
|
||||
(cmd_tx, evt_rx)
|
||||
}
|
||||
|
||||
struct EngineState {
|
||||
queue: Queue,
|
||||
speed: f32,
|
||||
eq: Option<Equalizer>,
|
||||
eq_gains: [f32; EQ_BANDS],
|
||||
status: PlaybackStatus,
|
||||
device_rate: u32,
|
||||
device_channels: usize,
|
||||
frames_decoded_native: u64, // абсолютная позиция в физическом файле, в native-сэмплах
|
||||
native_rate: u32,
|
||||
/// Абсолютное (от начала физического файла) начало текущего логического трека.
|
||||
/// Для обычных файлов — всегда ноль; для cue-треков — начало соответствующего диапазона.
|
||||
current_track_start: Duration,
|
||||
/// Абсолютный (от начала физического файла) конец текущего логического трека, если задан
|
||||
/// (cue-треки, кроме последнего в файле). При достижении — переход к следующему в очереди.
|
||||
current_track_end: Option<Duration>,
|
||||
/// true, пока играет интернет-радио: очередь/next/prev/repeat/seek не применяются,
|
||||
/// а обрыв потока просто останавливает воспроизведение вместо перехода дальше.
|
||||
is_radio: bool,
|
||||
/// Накопитель моно-сэмплов (пост-EQ, то есть то, что реально слышит пользователь)
|
||||
/// для спектрального анализатора.
|
||||
spectrum_buffer: Vec<f32>,
|
||||
last_spectrum_send: Instant,
|
||||
/// Плавное затухание громкости в последние секунды трека.
|
||||
fade_out_enabled: bool,
|
||||
/// Выравнивание громкости между треками (тег ReplayGain либо AGC-левеллер).
|
||||
loudness_norm_enabled: bool,
|
||||
/// Линейный коэффициент усиления из тега ReplayGain текущего трека, если есть.
|
||||
current_track_replay_gain: Option<f32>,
|
||||
}
|
||||
|
||||
fn run(cmd_rx: Receiver<PlayerCommand>, evt_tx: Sender<PlayerEvent>) -> anyhow::Result<()> {
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_output_device()
|
||||
.ok_or_else(|| anyhow::anyhow!("no output audio device found"))?;
|
||||
let supported = device.default_output_config()?;
|
||||
let device_rate = supported.sample_rate().0;
|
||||
let device_channels = supported.channels() as usize;
|
||||
let config: cpal::StreamConfig = supported.into();
|
||||
|
||||
// Ring buffer между потоком декодирования и cpal callback. Consumer обёрнут в Mutex,
|
||||
// чтобы движок мог его очистить при seek (иначе доиграл бы старое аудио ещё пару секунд).
|
||||
let rb = HeapRb::<f32>::new(device_rate as usize * device_channels * 2); // ~2 сек буфера
|
||||
let (mut producer, consumer) = rb.split();
|
||||
let consumer_shared: Arc<Mutex<HeapConsumer<f32>>> = Arc::new(Mutex::new(consumer));
|
||||
let consumer_for_stream = consumer_shared.clone();
|
||||
|
||||
let underrun_frames = Arc::new(AtomicU64::new(0));
|
||||
let paused_flag = Arc::new(AtomicBool::new(true));
|
||||
let paused_for_stream = paused_flag.clone();
|
||||
let muted_flag = Arc::new(AtomicBool::new(false));
|
||||
let muted_for_stream = muted_flag.clone();
|
||||
|
||||
let stream = device.build_output_stream(
|
||||
&config,
|
||||
move |data: &mut [f32], _| {
|
||||
if paused_for_stream.load(Ordering::Relaxed) {
|
||||
data.fill(0.0);
|
||||
return;
|
||||
}
|
||||
let muted = muted_for_stream.load(Ordering::Relaxed);
|
||||
let Ok(mut consumer) = consumer_for_stream.lock() else {
|
||||
data.fill(0.0);
|
||||
return;
|
||||
};
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
match consumer.pop() {
|
||||
Some(s) => {
|
||||
data[i] = if muted { 0.0 } else { s };
|
||||
i += 1;
|
||||
}
|
||||
None => {
|
||||
underrun_frames.fetch_add(1, Ordering::Relaxed);
|
||||
data[i] = 0.0;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
move |err| eprintln!("audio stream error: {err}"),
|
||||
None,
|
||||
)?;
|
||||
stream.play()?;
|
||||
|
||||
let mut state = EngineState {
|
||||
queue: Queue::default(),
|
||||
speed: 1.0,
|
||||
eq: None,
|
||||
eq_gains: [0.0; EQ_BANDS],
|
||||
status: PlaybackStatus::Stopped,
|
||||
device_rate,
|
||||
device_channels,
|
||||
frames_decoded_native: 0,
|
||||
native_rate: device_rate,
|
||||
current_track_start: Duration::ZERO,
|
||||
current_track_end: None,
|
||||
is_radio: 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,
|
||||
};
|
||||
|
||||
let mut current_track: Option<DecodedTrack> = None;
|
||||
let mut resampler: Option<SpeedResampler> = None;
|
||||
let mut spectrum_analyzer = SpectrumAnalyzer::new();
|
||||
let mut leveler = Leveler::new();
|
||||
|
||||
loop {
|
||||
// Обрабатываем все накопившиеся команды не блокируясь.
|
||||
loop {
|
||||
match cmd_rx.try_recv() {
|
||||
Ok(cmd) => handle_command(
|
||||
cmd,
|
||||
&mut state,
|
||||
&mut current_track,
|
||||
&mut resampler,
|
||||
&paused_flag,
|
||||
&muted_flag,
|
||||
&consumer_shared,
|
||||
&evt_tx,
|
||||
),
|
||||
Err(TryRecvError::Empty) => break,
|
||||
Err(TryRecvError::Disconnected) => return Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
if state.status == PlaybackStatus::Playing && current_track.is_some() {
|
||||
let need_more = producer.free_len() > 4096;
|
||||
if need_more {
|
||||
decode_and_push(
|
||||
&mut state,
|
||||
&mut current_track,
|
||||
&mut resampler,
|
||||
&mut producer,
|
||||
&paused_flag,
|
||||
&evt_tx,
|
||||
&mut spectrum_analyzer,
|
||||
&mut leveler,
|
||||
)?;
|
||||
} else {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
let _ = evt_tx.send(PlayerEvent::Position(current_position(&state)));
|
||||
} else {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn absolute_position(state: &EngineState) -> Duration {
|
||||
if state.native_rate == 0 {
|
||||
return Duration::ZERO;
|
||||
}
|
||||
Duration::from_secs_f64(state.frames_decoded_native as f64 / state.native_rate as f64)
|
||||
}
|
||||
|
||||
/// Позиция относительно начала ТЕКУЩЕГО логического трека — то, что видит пользователь
|
||||
/// на прогресс-баре (для cue-трека это не то же самое, что позиция в физическом файле).
|
||||
fn current_position(state: &EngineState) -> Duration {
|
||||
absolute_position(state).saturating_sub(state.current_track_start)
|
||||
}
|
||||
|
||||
/// Длительность затухания в конце трека.
|
||||
const FADE_OUT_DURATION: Duration = Duration::from_secs(4);
|
||||
|
||||
/// Коэффициент усиления для плавного затухания в последние `FADE_OUT_DURATION` секунд
|
||||
/// трека. `None`, если конец трека неизвестен (например, интернет-радио — там затухать
|
||||
/// нечему, там нет конца).
|
||||
fn fade_gain_at(current_track_end: Option<Duration>, absolute_pos: Duration) -> Option<f32> {
|
||||
let end = current_track_end?;
|
||||
if absolute_pos >= end {
|
||||
return Some(0.0);
|
||||
}
|
||||
let remaining = end - absolute_pos;
|
||||
if remaining >= FADE_OUT_DURATION {
|
||||
return Some(1.0);
|
||||
}
|
||||
Some((remaining.as_secs_f32() / FADE_OUT_DURATION.as_secs_f32()).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
fn fade_gain(state: &EngineState) -> Option<f32> {
|
||||
fade_gain_at(state.current_track_end, absolute_position(state))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fade_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_fade_far_from_end() {
|
||||
let end = Some(Duration::from_secs(200));
|
||||
let pos = Duration::from_secs(10);
|
||||
assert_eq!(fade_gain_at(end, pos), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fades_linearly_in_last_window() {
|
||||
let end = Some(Duration::from_secs(200));
|
||||
// ровно на середине окна затухания (4с) -> примерно половина громкости
|
||||
let pos = end.unwrap() - Duration::from_secs(2);
|
||||
let gain = fade_gain_at(end, pos).unwrap();
|
||||
assert!((gain - 0.5).abs() < 0.01, "gain={gain}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silent_at_or_past_end() {
|
||||
let end = Some(Duration::from_secs(200));
|
||||
assert_eq!(fade_gain_at(end, Duration::from_secs(200)), Some(0.0));
|
||||
assert_eq!(fade_gain_at(end, Duration::from_secs(250)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_fade_when_end_unknown() {
|
||||
// например, интернет-радио — у него нет известного конца
|
||||
assert_eq!(fade_gain_at(None, Duration::from_secs(999)), None);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_command(
|
||||
cmd: PlayerCommand,
|
||||
state: &mut EngineState,
|
||||
current_track: &mut Option<DecodedTrack>,
|
||||
resampler: &mut Option<SpeedResampler>,
|
||||
paused_flag: &Arc<AtomicBool>,
|
||||
muted_flag: &Arc<AtomicBool>,
|
||||
consumer_shared: &Arc<Mutex<HeapConsumer<f32>>>,
|
||||
evt_tx: &Sender<PlayerEvent>,
|
||||
) {
|
||||
match cmd {
|
||||
PlayerCommand::OpenPath(path, mode) => {
|
||||
state.is_radio = false;
|
||||
let tracks = match mode {
|
||||
OpenMode::SingleTrack => vec![QueueItem::whole_file(path.clone())],
|
||||
OpenMode::WholeAlbum => match sibling_album_tracks(&path) {
|
||||
Ok(t) => t.into_iter().map(QueueItem::whole_file).collect(),
|
||||
Err(e) => {
|
||||
let _ = evt_tx.send(PlayerEvent::Error(e.to_string()));
|
||||
vec![QueueItem::whole_file(path.clone())]
|
||||
}
|
||||
},
|
||||
};
|
||||
let start_idx = tracks.iter().position(|t| t.path == path).unwrap_or(0);
|
||||
state.queue.set_tracks(tracks, start_idx);
|
||||
load_current(state, current_track, resampler, paused_flag, evt_tx);
|
||||
}
|
||||
PlayerCommand::SetQueue(tracks, start_idx) => {
|
||||
state.is_radio = false;
|
||||
state.queue.set_tracks(tracks, start_idx);
|
||||
load_current(state, current_track, resampler, paused_flag, evt_tx);
|
||||
}
|
||||
PlayerCommand::Enqueue(path) => {
|
||||
state.queue.push(QueueItem::whole_file(path));
|
||||
}
|
||||
PlayerCommand::PlayRadio { name, url } => {
|
||||
state.is_radio = true;
|
||||
state.current_track_start = Duration::ZERO;
|
||||
state.current_track_end = None;
|
||||
match open_url(&url, evt_tx.clone()) {
|
||||
Ok(track) => {
|
||||
install_track(state, current_track, resampler, paused_flag, evt_tx, track);
|
||||
let _ = evt_tx.send(PlayerEvent::TrackChanged(TrackInfo {
|
||||
path: PathBuf::from(&url),
|
||||
title: name,
|
||||
artist: "Internet Radio".to_string(),
|
||||
album: url,
|
||||
duration: None,
|
||||
cover: None,
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = evt_tx.send(PlayerEvent::Error(format!(
|
||||
"failed to open radio stream: {e}"
|
||||
)));
|
||||
state.status = PlaybackStatus::Stopped;
|
||||
}
|
||||
}
|
||||
}
|
||||
PlayerCommand::PlayPause => {
|
||||
state.status = match state.status {
|
||||
PlaybackStatus::Playing => {
|
||||
paused_flag.store(true, Ordering::Relaxed);
|
||||
PlaybackStatus::Paused
|
||||
}
|
||||
PlaybackStatus::Paused | PlaybackStatus::Stopped => {
|
||||
if current_track.is_some() {
|
||||
paused_flag.store(false, Ordering::Relaxed);
|
||||
PlaybackStatus::Playing
|
||||
} else {
|
||||
PlaybackStatus::Stopped
|
||||
}
|
||||
}
|
||||
};
|
||||
let _ = evt_tx.send(PlayerEvent::StatusChanged(state.status));
|
||||
}
|
||||
PlayerCommand::Stop => {
|
||||
state.status = PlaybackStatus::Stopped;
|
||||
paused_flag.store(true, Ordering::Relaxed);
|
||||
*current_track = None;
|
||||
state.is_radio = false;
|
||||
state.spectrum_buffer.clear();
|
||||
state.current_track_replay_gain = None;
|
||||
let _ = evt_tx.send(PlayerEvent::StatusChanged(state.status));
|
||||
let _ = evt_tx.send(PlayerEvent::Spectrum(vec![0.0; crate::spectrum::SPECTRUM_BANDS]));
|
||||
}
|
||||
PlayerCommand::Next => {
|
||||
if state.is_radio {
|
||||
return; // радио: следующего трека не существует
|
||||
}
|
||||
if state.queue.advance() {
|
||||
load_current(state, current_track, resampler, paused_flag, evt_tx);
|
||||
} else {
|
||||
state.status = PlaybackStatus::Stopped;
|
||||
paused_flag.store(true, Ordering::Relaxed);
|
||||
let _ = evt_tx.send(PlayerEvent::QueueFinished);
|
||||
}
|
||||
}
|
||||
PlayerCommand::Previous => {
|
||||
if state.is_radio {
|
||||
return;
|
||||
}
|
||||
if state.queue.previous() {
|
||||
load_current(state, current_track, resampler, paused_flag, evt_tx);
|
||||
}
|
||||
}
|
||||
PlayerCommand::Seek(requested_relative) => {
|
||||
if state.is_radio {
|
||||
let _ = evt_tx.send(PlayerEvent::Error(
|
||||
"can't seek in a live radio stream".to_string(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
let Some(track) = current_track.as_mut() else {
|
||||
return;
|
||||
};
|
||||
// requested_relative — позиция от начала ТЕКУЩЕГО логического трека; переводим
|
||||
// в абсолютную позицию внутри физического файла и не даём выйти за его границу.
|
||||
let mut absolute_target = state.current_track_start + requested_relative;
|
||||
if let Some(end) = state.current_track_end {
|
||||
absolute_target = absolute_target.min(end);
|
||||
}
|
||||
match decode_seek(track, absolute_target) {
|
||||
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();
|
||||
let _ = evt_tx.send(PlayerEvent::Position(current_position(state)));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = evt_tx.send(PlayerEvent::Error(format!("seek failed: {e}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
let _ = evt_tx.send(PlayerEvent::SpeedChanged(speed));
|
||||
}
|
||||
PlayerCommand::ToggleShuffle => {
|
||||
state.queue.set_shuffle(!state.queue.shuffle);
|
||||
let _ = evt_tx.send(PlayerEvent::ShuffleChanged(state.queue.shuffle));
|
||||
}
|
||||
PlayerCommand::CycleRepeat => {
|
||||
state.queue.cycle_repeat();
|
||||
let _ = evt_tx.send(PlayerEvent::RepeatChanged(state.queue.repeat));
|
||||
}
|
||||
PlayerCommand::SetEqBand(band, gain) => {
|
||||
state.eq_gains[band] = gain;
|
||||
if let Some(eq) = state.eq.as_mut() {
|
||||
eq.set_band(band, gain);
|
||||
}
|
||||
let _ = evt_tx.send(PlayerEvent::EqChanged(state.eq_gains));
|
||||
}
|
||||
PlayerCommand::ResetEq => {
|
||||
state.eq_gains = [0.0; EQ_BANDS];
|
||||
if let Some(eq) = state.eq.as_mut() {
|
||||
eq.reset();
|
||||
}
|
||||
let _ = evt_tx.send(PlayerEvent::EqChanged(state.eq_gains));
|
||||
}
|
||||
PlayerCommand::ToggleMute => {
|
||||
let new_muted = !muted_flag.load(Ordering::Relaxed);
|
||||
muted_flag.store(new_muted, Ordering::Relaxed);
|
||||
let _ = evt_tx.send(PlayerEvent::MuteChanged(new_muted));
|
||||
}
|
||||
PlayerCommand::ToggleFadeOut => {
|
||||
state.fade_out_enabled = !state.fade_out_enabled;
|
||||
let _ = evt_tx.send(PlayerEvent::FadeOutChanged(state.fade_out_enabled));
|
||||
}
|
||||
PlayerCommand::ToggleLoudnessNorm => {
|
||||
state.loudness_norm_enabled = !state.loudness_norm_enabled;
|
||||
let _ = evt_tx.send(PlayerEvent::LoudnessNormChanged(state.loudness_norm_enabled));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Общая часть загрузки декодированного трека в состояние движка (используется и файлами, и радио).
|
||||
fn install_track(
|
||||
state: &mut EngineState,
|
||||
current_track: &mut Option<DecodedTrack>,
|
||||
resampler: &mut Option<SpeedResampler>,
|
||||
paused_flag: &Arc<AtomicBool>,
|
||||
evt_tx: &Sender<PlayerEvent>,
|
||||
track: DecodedTrack,
|
||||
) {
|
||||
state.native_rate = track.spec.rate;
|
||||
state.frames_decoded_native = 0;
|
||||
let channels = state.device_channels;
|
||||
state.eq = Some(Equalizer::new(track.spec.rate as f32, channels));
|
||||
for (band, gain) in state.eq_gains.iter().enumerate() {
|
||||
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();
|
||||
*current_track = Some(track);
|
||||
state.status = PlaybackStatus::Playing;
|
||||
paused_flag.store(false, Ordering::Relaxed);
|
||||
let _ = evt_tx.send(PlayerEvent::StatusChanged(state.status));
|
||||
}
|
||||
|
||||
fn load_current(
|
||||
state: &mut EngineState,
|
||||
current_track: &mut Option<DecodedTrack>,
|
||||
resampler: &mut Option<SpeedResampler>,
|
||||
paused_flag: &Arc<AtomicBool>,
|
||||
evt_tx: &Sender<PlayerEvent>,
|
||||
) {
|
||||
let Some(item) = state.queue.current_item().cloned() else {
|
||||
*current_track = None;
|
||||
return;
|
||||
};
|
||||
let queue_index = state.queue.current_index();
|
||||
|
||||
match open_track(&item.path) {
|
||||
Ok(mut track) => {
|
||||
// Виртуальный трек (например, кусок cue-листа) — сразу прыгаем на его начало
|
||||
// внутри физического файла, вместо проигрывания с самого начала файла.
|
||||
if item.start > Duration::ZERO {
|
||||
if let Err(e) = decode_seek(&mut track, item.start) {
|
||||
let _ = evt_tx.send(PlayerEvent::Error(format!(
|
||||
"seek to track start failed: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
state.current_track_start = item.start;
|
||||
// Для обычных (не-cue) файлов item.end всегда None — используем длительность
|
||||
// самого файла (если известна), чтобы затухание в конце работало и для них,
|
||||
// а не только для cue-треков.
|
||||
state.current_track_end = item.end.or(track.duration);
|
||||
state.current_track_replay_gain = item
|
||||
.replay_gain_db
|
||||
.map(|db| 10f32.powf(db / 20.0));
|
||||
|
||||
install_track(state, current_track, resampler, paused_flag, evt_tx, track);
|
||||
if item.start > Duration::ZERO {
|
||||
state.frames_decoded_native =
|
||||
(item.start.as_secs_f64() * state.native_rate as f64) as u64;
|
||||
}
|
||||
|
||||
let mut info = read_tags(&item.path).unwrap_or_else(|_| TrackInfo {
|
||||
path: item.path.clone(),
|
||||
title: item
|
||||
.path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string(),
|
||||
artist: "Unknown Artist".to_string(),
|
||||
album: "Unknown Album".to_string(),
|
||||
duration: None,
|
||||
cover: None,
|
||||
});
|
||||
if let Some(title) = &item.title_override {
|
||||
info.title = title.clone();
|
||||
}
|
||||
if let Some(artist) = &item.artist_override {
|
||||
info.artist = artist.clone();
|
||||
}
|
||||
if let Some(end) = item.end {
|
||||
info.duration = Some(end.saturating_sub(item.start));
|
||||
} else if item.start > Duration::ZERO {
|
||||
// последний трек cue-листа: длительность = длительность файла минус start
|
||||
info.duration = info.duration.map(|d| d.saturating_sub(item.start));
|
||||
}
|
||||
let _ = evt_tx.send(PlayerEvent::TrackChanged(info));
|
||||
if let Some(idx) = queue_index {
|
||||
let _ = evt_tx.send(PlayerEvent::QueueIndexChanged(idx));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = evt_tx.send(PlayerEvent::Error(format!(
|
||||
"failed to open {}: {e}",
|
||||
item.path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Общая логика "трек закончился (физически или по границе cue) — что делать дальше".
|
||||
fn advance_or_finish(
|
||||
state: &mut EngineState,
|
||||
current_track: &mut Option<DecodedTrack>,
|
||||
resampler: &mut Option<SpeedResampler>,
|
||||
paused_flag: &Arc<AtomicBool>,
|
||||
evt_tx: &Sender<PlayerEvent>,
|
||||
) {
|
||||
if state.is_radio {
|
||||
state.status = PlaybackStatus::Stopped;
|
||||
paused_flag.store(true, Ordering::Relaxed);
|
||||
*current_track = None;
|
||||
let _ = evt_tx.send(PlayerEvent::QueueFinished);
|
||||
return;
|
||||
}
|
||||
if state.queue.advance() {
|
||||
load_current(state, current_track, resampler, paused_flag, evt_tx);
|
||||
} else {
|
||||
state.status = PlaybackStatus::Stopped;
|
||||
paused_flag.store(true, Ordering::Relaxed);
|
||||
*current_track = None;
|
||||
let _ = evt_tx.send(PlayerEvent::QueueFinished);
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_and_push(
|
||||
state: &mut EngineState,
|
||||
current_track: &mut Option<DecodedTrack>,
|
||||
resampler: &mut Option<SpeedResampler>,
|
||||
producer: &mut HeapProducer<f32>,
|
||||
paused_flag: &Arc<AtomicBool>,
|
||||
evt_tx: &Sender<PlayerEvent>,
|
||||
spectrum_analyzer: &mut SpectrumAnalyzer,
|
||||
leveler: &mut Leveler,
|
||||
) -> anyhow::Result<()> {
|
||||
let Some(track) = current_track.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
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),
|
||||
};
|
||||
|
||||
match packet_result? {
|
||||
Some(mut samples) => {
|
||||
let frames = samples.len() / track.spec.channels.count();
|
||||
state.frames_decoded_native += frames as u64;
|
||||
|
||||
if let Some(eq) = state.eq.as_mut() {
|
||||
eq.process_interleaved(&mut samples);
|
||||
}
|
||||
|
||||
let channels = track.spec.channels.count().max(1);
|
||||
|
||||
if state.loudness_norm_enabled {
|
||||
if let Some(gain) = state.current_track_replay_gain {
|
||||
// Точное значение из тега ReplayGain — просто статическое усиление.
|
||||
for s in samples.iter_mut() {
|
||||
*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));
|
||||
}
|
||||
}
|
||||
|
||||
// Достигли конца виртуального (cue) трека внутри файла — переходим дальше,
|
||||
// не дожидаясь физического EOF файла.
|
||||
if let Some(end) = state.current_track_end {
|
||||
if absolute_position(state) >= end {
|
||||
advance_or_finish(state, current_track, resampler, paused_flag, evt_tx);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
// физический конец файла — переходим дальше согласно repeat/shuffle (для файлов)
|
||||
// либо просто останавливаемся (для радио: следующего трека не существует)
|
||||
if let Some(r) = resampler.as_mut() {
|
||||
if let Ok(tail) = r.flush() {
|
||||
for s in tail {
|
||||
let _ = producer.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
advance_or_finish(state, current_track, resampler, paused_flag, evt_tx);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Для WholeAlbum: находим все аудиофайлы в той же папке, что и path, сортируем по имени
|
||||
/// (простая эвристика для MVP; сортировку по номеру трека из тегов добавим на уровне library).
|
||||
fn sibling_album_tracks(path: &PathBuf) -> anyhow::Result<Vec<PathBuf>> {
|
||||
let dir = path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("no parent directory"))?;
|
||||
let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|ext| {
|
||||
matches!(
|
||||
ext.to_lowercase().as_str(),
|
||||
"mp3" | "flac" | "ogg" | "wav" | "m4a"
|
||||
)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
entries.sort();
|
||||
Ok(entries)
|
||||
}
|
||||
115
crates/player/src/eq.rs
Normal file
115
crates/player/src/eq.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
//! Простой графический эквалайзер: каскад peaking-биквадов, по одному на полосу,
|
||||
//! с отдельным состоянием (history) на каждый аудиоканал.
|
||||
|
||||
use crate::types::{EQ_BANDS, EQ_FREQS};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct BiquadCoeffs {
|
||||
b0: f32,
|
||||
b1: f32,
|
||||
b2: f32,
|
||||
a1: f32,
|
||||
a2: f32,
|
||||
}
|
||||
|
||||
impl BiquadCoeffs {
|
||||
/// RBJ Audio EQ Cookbook: peaking EQ.
|
||||
fn peaking(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self {
|
||||
let a = 10f32.powf(gain_db / 40.0);
|
||||
let w0 = 2.0 * std::f32::consts::PI * freq / sample_rate;
|
||||
let alpha = w0.sin() / (2.0 * q);
|
||||
let cos_w0 = w0.cos();
|
||||
|
||||
let b0 = 1.0 + alpha * a;
|
||||
let b1 = -2.0 * cos_w0;
|
||||
let b2 = 1.0 - alpha * a;
|
||||
let a0 = 1.0 + alpha / a;
|
||||
let a1 = -2.0 * cos_w0;
|
||||
let a2 = 1.0 - alpha / a;
|
||||
|
||||
BiquadCoeffs {
|
||||
b0: b0 / a0,
|
||||
b1: b1 / a0,
|
||||
b2: b2 / a0,
|
||||
a1: a1 / a0,
|
||||
a2: a2 / a0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct BiquadState {
|
||||
x1: f32,
|
||||
x2: f32,
|
||||
y1: f32,
|
||||
y2: f32,
|
||||
}
|
||||
|
||||
impl BiquadState {
|
||||
#[inline]
|
||||
fn process(&mut self, c: &BiquadCoeffs, x0: f32) -> f32 {
|
||||
let y0 = c.b0 * x0 + c.b1 * self.x1 + c.b2 * self.x2 - c.a1 * self.y1 - c.a2 * self.y2;
|
||||
self.x2 = self.x1;
|
||||
self.x1 = x0;
|
||||
self.y2 = self.y1;
|
||||
self.y1 = y0;
|
||||
y0
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Equalizer {
|
||||
sample_rate: f32,
|
||||
channels: usize,
|
||||
gains_db: [f32; EQ_BANDS],
|
||||
coeffs: [BiquadCoeffs; EQ_BANDS],
|
||||
/// state[band][channel]
|
||||
state: Vec<[BiquadState; EQ_BANDS]>,
|
||||
}
|
||||
|
||||
impl Equalizer {
|
||||
pub fn new(sample_rate: f32, channels: usize) -> Self {
|
||||
let gains_db = [0.0; EQ_BANDS];
|
||||
let coeffs = Self::build_coeffs(sample_rate, &gains_db);
|
||||
Equalizer {
|
||||
sample_rate,
|
||||
channels,
|
||||
gains_db,
|
||||
coeffs,
|
||||
state: vec![[BiquadState::default(); EQ_BANDS]; channels],
|
||||
}
|
||||
}
|
||||
|
||||
fn build_coeffs(sample_rate: f32, gains_db: &[f32; EQ_BANDS]) -> [BiquadCoeffs; EQ_BANDS] {
|
||||
let mut coeffs = [BiquadCoeffs::default(); EQ_BANDS];
|
||||
for i in 0..EQ_BANDS {
|
||||
coeffs[i] = BiquadCoeffs::peaking(sample_rate, EQ_FREQS[i], gains_db[i], 1.0);
|
||||
}
|
||||
coeffs
|
||||
}
|
||||
|
||||
pub fn set_band(&mut self, band: usize, gain_db: f32) {
|
||||
if band >= EQ_BANDS {
|
||||
return;
|
||||
}
|
||||
let gain_db = gain_db.clamp(-12.0, 12.0);
|
||||
self.gains_db[band] = gain_db;
|
||||
self.coeffs[band] = BiquadCoeffs::peaking(self.sample_rate, EQ_FREQS[band], gain_db, 1.0);
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.gains_db = [0.0; EQ_BANDS];
|
||||
self.coeffs = Self::build_coeffs(self.sample_rate, &self.gains_db);
|
||||
}
|
||||
|
||||
/// Обрабатывает interleaved-буфер сэмплов на месте.
|
||||
pub fn process_interleaved(&mut self, buf: &mut [f32]) {
|
||||
for (i, sample) in buf.iter_mut().enumerate() {
|
||||
let ch = i % self.channels;
|
||||
let mut x = *sample;
|
||||
for band in 0..EQ_BANDS {
|
||||
x = self.state[ch][band].process(&self.coeffs[band], x);
|
||||
}
|
||||
*sample = x;
|
||||
}
|
||||
}
|
||||
}
|
||||
132
crates/player/src/leveler.rs
Normal file
132
crates/player/src/leveler.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
//! Динамический левеллер громкости (fallback, когда в файле нет тега ReplayGain):
|
||||
//! постоянно измеряет громкость последних секунд звука и плавно подстраивает усиление
|
||||
//! к целевому уровню. В отличие от настоящего ReplayGain (посчитан заранее по всему
|
||||
//! треку), это адаптация "на лету" — первые секунды трека выравниваются не идеально,
|
||||
//! дальше подстройка сходится к цели.
|
||||
|
||||
const TARGET_RMS: f32 = 0.1; // примерно -20 dBFS
|
||||
const MIN_GAIN: f32 = 0.1; // макс. ослабление -20 дБ (для "громких" мастерингов, близких к 0dBFS)
|
||||
const MAX_GAIN: f32 = 4.0; // макс. усиление +12 дБ (не тащим почти-тишину слишком громко)
|
||||
const POWER_TIME_CONSTANT_SECS: f32 = 2.0;
|
||||
const GAIN_TIME_CONSTANT_SECS: f32 = 1.0;
|
||||
|
||||
pub struct Leveler {
|
||||
ema_power: f32,
|
||||
current_gain: f32,
|
||||
}
|
||||
|
||||
impl Leveler {
|
||||
pub fn new() -> Self {
|
||||
Leveler {
|
||||
ema_power: TARGET_RMS * TARGET_RMS,
|
||||
current_gain: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Обрабатывает interleaved-буфер на месте. `channels`/`sample_rate` нужны, чтобы
|
||||
/// сглаживание было привязано ко времени, а не к размеру пакета декодера (иначе
|
||||
/// скорость адаптации плясала бы в зависимости от формата/битрейта файла).
|
||||
pub fn process(&mut self, samples: &mut [f32], channels: usize, sample_rate: f32) {
|
||||
if samples.is_empty() || channels == 0 || sample_rate <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let n = samples.len() as f32;
|
||||
let block_power: f32 = samples.iter().map(|s| s * s).sum::<f32>() / n;
|
||||
let frames = n / channels as f32;
|
||||
let block_duration = (frames / sample_rate).max(0.0001);
|
||||
|
||||
let power_alpha = 1.0 - (-block_duration / POWER_TIME_CONSTANT_SECS).exp();
|
||||
self.ema_power = self.ema_power * (1.0 - power_alpha) + block_power * power_alpha;
|
||||
|
||||
let rms = self.ema_power.sqrt().max(1e-6);
|
||||
let desired_gain = (TARGET_RMS / rms).clamp(MIN_GAIN, MAX_GAIN);
|
||||
|
||||
let gain_alpha = 1.0 - (-block_duration / GAIN_TIME_CONSTANT_SECS).exp();
|
||||
self.current_gain = self.current_gain * (1.0 - gain_alpha) + desired_gain * gain_alpha;
|
||||
|
||||
for s in samples.iter_mut() {
|
||||
*s *= self.current_gain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Leveler {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rms(samples: &[f32]) -> f32 {
|
||||
(samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32).sqrt()
|
||||
}
|
||||
|
||||
/// Прогоняет несколько секунд синусоиды заданной амплитуды через левеллер и
|
||||
/// возвращает RMS последнего блока (когда сглаживание уже сошлось).
|
||||
fn converged_rms(amplitude: f32) -> f32 {
|
||||
let sample_rate = 44100.0;
|
||||
let block_frames = 4096;
|
||||
let mut leveler = Leveler::new();
|
||||
let mut last_block_rms = 0.0;
|
||||
// ~28 секунд звука — оба каскада сглаживания (мощность 2с + усиление 1с) успевают
|
||||
// сойтись за много постоянных времени каждый (в реальном прослушивании счёт на минуты)
|
||||
for block in 0..300 {
|
||||
let mut samples: Vec<f32> = (0..block_frames)
|
||||
.map(|i| {
|
||||
let t = (block * block_frames + i) as f32 / sample_rate;
|
||||
amplitude * (2.0 * std::f32::consts::PI * 440.0 * t).sin()
|
||||
})
|
||||
.collect();
|
||||
leveler.process(&mut samples, 1, sample_rate);
|
||||
last_block_rms = rms(&samples);
|
||||
}
|
||||
last_block_rms
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loud_and_quiet_signals_converge_to_similar_output_level() {
|
||||
let loud_out = converged_rms(0.9); // громкий сигнал (близко к клиппингу)
|
||||
let quiet_out = converged_rms(0.04); // тихий, но не настолько, чтобы упереться в MAX_GAIN
|
||||
|
||||
// оба должны приблизиться к целевому RMS, а не остаться на исходных уровнях
|
||||
assert!(
|
||||
(loud_out - TARGET_RMS).abs() < TARGET_RMS * 0.5,
|
||||
"loud_out={loud_out}, target={TARGET_RMS}"
|
||||
);
|
||||
assert!(
|
||||
(quiet_out - TARGET_RMS).abs() < TARGET_RMS * 0.5,
|
||||
"quiet_out={quiet_out}, target={TARGET_RMS}"
|
||||
);
|
||||
// и главное — они должны оказаться близко друг к другу после выравнивания
|
||||
assert!(
|
||||
(loud_out - quiet_out).abs() < TARGET_RMS * 0.3,
|
||||
"loud_out={loud_out}, quiet_out={quiet_out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_amplify_near_silence_excessively() {
|
||||
let sample_rate = 44100.0;
|
||||
let mut leveler = Leveler::new();
|
||||
let mut samples = vec![0.0001f32; 4096]; // почти тишина
|
||||
leveler.process(&mut samples, 1, sample_rate);
|
||||
// усиление ограничено MAX_GAIN, а не улетает в бесконечность на почти-нулевом сигнале
|
||||
assert!(samples.iter().all(|s| s.abs() <= 0.0001 * MAX_GAIN + 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extremely_quiet_signal_hits_max_gain_ceiling_by_design() {
|
||||
// Сигнал настолько тихий, что даже максимальное разрешённое усиление (+12дБ)
|
||||
// не дотягивает до целевого RMS — это осознанное ограничение (не тащим шум/тишину
|
||||
// до полной громкости), а не недостижение сходимости.
|
||||
let out = converged_rms(0.02);
|
||||
let expected_ceiling = (0.02f32 / std::f32::consts::SQRT_2) * MAX_GAIN;
|
||||
assert!(
|
||||
(out - expected_ceiling).abs() < expected_ceiling * 0.1,
|
||||
"expected to converge to the MAX_GAIN ceiling ~{expected_ceiling}, got {out}"
|
||||
);
|
||||
}
|
||||
}
|
||||
152
crates/player/src/lib.rs
Normal file
152
crates/player/src/lib.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
mod decode;
|
||||
mod engine;
|
||||
mod eq;
|
||||
mod leveler;
|
||||
mod queue;
|
||||
mod resample;
|
||||
mod spectrum;
|
||||
pub mod types;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
|
||||
|
||||
pub use types::*;
|
||||
pub use spectrum::SPECTRUM_BANDS;
|
||||
|
||||
/// Хэндл для управления плеером из UI-слоя. Само воспроизведение работает
|
||||
/// в фоновом потоке; здесь только неблокирующая отправка команд и приём событий.
|
||||
pub struct Player {
|
||||
cmd_tx: Sender<PlayerCommand>,
|
||||
evt_rx: Receiver<PlayerEvent>,
|
||||
disconnected_reported: std::cell::Cell<bool>,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
pub fn new() -> Self {
|
||||
let (cmd_tx, evt_rx) = engine::spawn();
|
||||
Player {
|
||||
cmd_tx,
|
||||
evt_rx,
|
||||
disconnected_reported: std::cell::Cell::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn send(&self, cmd: PlayerCommand) -> bool {
|
||||
let ok = self.cmd_tx.send(cmd).is_ok();
|
||||
if !ok {
|
||||
self.disconnected_reported.set(true);
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
/// true, если фоновый поток движка ещё жив (канал команд не разорван).
|
||||
pub fn is_alive(&self) -> bool {
|
||||
!self.disconnected_reported.get()
|
||||
}
|
||||
|
||||
pub fn open_single(&self, path: PathBuf) -> bool {
|
||||
self.send(PlayerCommand::OpenPath(path, OpenMode::SingleTrack))
|
||||
}
|
||||
|
||||
pub fn open_album(&self, path: PathBuf) -> bool {
|
||||
self.send(PlayerCommand::OpenPath(path, OpenMode::WholeAlbum))
|
||||
}
|
||||
|
||||
pub fn set_queue(&self, tracks: Vec<QueueItem>, start_at: usize) -> bool {
|
||||
self.send(PlayerCommand::SetQueue(tracks, start_at))
|
||||
}
|
||||
|
||||
pub fn enqueue(&self, path: PathBuf) -> bool {
|
||||
self.send(PlayerCommand::Enqueue(path))
|
||||
}
|
||||
|
||||
pub fn play_radio(&self, name: impl Into<String>, url: impl Into<String>) -> bool {
|
||||
self.send(PlayerCommand::PlayRadio {
|
||||
name: name.into(),
|
||||
url: url.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn toggle_mute(&self) -> bool {
|
||||
self.send(PlayerCommand::ToggleMute)
|
||||
}
|
||||
|
||||
pub fn toggle_fade_out(&self) -> bool {
|
||||
self.send(PlayerCommand::ToggleFadeOut)
|
||||
}
|
||||
|
||||
pub fn toggle_loudness_norm(&self) -> bool {
|
||||
self.send(PlayerCommand::ToggleLoudnessNorm)
|
||||
}
|
||||
|
||||
pub fn play_pause(&self) -> bool {
|
||||
self.send(PlayerCommand::PlayPause)
|
||||
}
|
||||
|
||||
pub fn stop(&self) -> bool {
|
||||
self.send(PlayerCommand::Stop)
|
||||
}
|
||||
|
||||
pub fn next(&self) -> bool {
|
||||
self.send(PlayerCommand::Next)
|
||||
}
|
||||
|
||||
pub fn previous(&self) -> bool {
|
||||
self.send(PlayerCommand::Previous)
|
||||
}
|
||||
|
||||
pub fn seek(&self, position: std::time::Duration) -> bool {
|
||||
self.send(PlayerCommand::Seek(position))
|
||||
}
|
||||
|
||||
pub fn set_speed(&self, speed: Speed) -> bool {
|
||||
self.send(PlayerCommand::SetSpeed(speed.0))
|
||||
}
|
||||
|
||||
pub fn toggle_shuffle(&self) -> bool {
|
||||
self.send(PlayerCommand::ToggleShuffle)
|
||||
}
|
||||
|
||||
pub fn cycle_repeat(&self) -> bool {
|
||||
self.send(PlayerCommand::CycleRepeat)
|
||||
}
|
||||
|
||||
pub fn set_eq_band(&self, band: usize, gain_db: f32) -> bool {
|
||||
self.send(PlayerCommand::SetEqBand(band, gain_db))
|
||||
}
|
||||
|
||||
pub fn reset_eq(&self) -> bool {
|
||||
self.send(PlayerCommand::ResetEq)
|
||||
}
|
||||
|
||||
/// Неблокирующий опрос событий — вызывать из UI event loop каждый тик.
|
||||
/// Если фоновый поток движка завершился (например, аудиоустройство пропало),
|
||||
/// это будет один раз отражено явной ошибкой вместо молчаливой потери команд.
|
||||
pub fn poll_events(&self) -> Vec<PlayerEvent> {
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
match self.evt_rx.try_recv() {
|
||||
Ok(e) => out.push(e),
|
||||
Err(TryRecvError::Empty) => break,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
if !self.disconnected_reported.get() {
|
||||
out.push(PlayerEvent::Error(
|
||||
"Player engine stopped running (audio device disconnected?). \
|
||||
Commands will no longer work — restart the app to resume playback."
|
||||
.to_string(),
|
||||
));
|
||||
self.disconnected_reported.set(true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Player {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
206
crates/player/src/queue.rs
Normal file
206
crates/player/src/queue.rs
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
use rand::seq::SliceRandom;
|
||||
use rand::thread_rng;
|
||||
|
||||
use crate::types::{QueueItem, RepeatMode};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Queue {
|
||||
/// Оригинальный порядок (как задан пользователем/альбомом/плейлистом/cue-листом).
|
||||
original: Vec<QueueItem>,
|
||||
/// Порядок воспроизведения — либо совпадает с original, либо перемешан.
|
||||
order: Vec<usize>,
|
||||
current: Option<usize>, // индекс в order
|
||||
pub shuffle: bool,
|
||||
pub repeat: RepeatMode,
|
||||
}
|
||||
|
||||
impl Queue {
|
||||
pub fn set_tracks(&mut self, tracks: Vec<QueueItem>, start_at: usize) {
|
||||
self.original = tracks;
|
||||
self.rebuild_order(Some(start_at));
|
||||
}
|
||||
|
||||
pub fn push(&mut self, item: QueueItem) {
|
||||
self.original.push(item);
|
||||
let new_idx = self.original.len() - 1;
|
||||
self.order.push(new_idx);
|
||||
}
|
||||
|
||||
fn rebuild_order(&mut self, keep_track_original_idx: Option<usize>) {
|
||||
self.order = (0..self.original.len()).collect();
|
||||
if self.shuffle {
|
||||
self.order.shuffle(&mut thread_rng());
|
||||
}
|
||||
self.current = keep_track_original_idx.and_then(|orig_idx| {
|
||||
self.order.iter().position(|&i| i == orig_idx)
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_shuffle(&mut self, shuffle: bool) {
|
||||
if self.shuffle == shuffle {
|
||||
return;
|
||||
}
|
||||
self.shuffle = shuffle;
|
||||
let current_orig = self.current_original_index();
|
||||
self.rebuild_order(current_orig);
|
||||
}
|
||||
|
||||
pub fn cycle_repeat(&mut self) {
|
||||
self.repeat = self.repeat.cycle();
|
||||
}
|
||||
|
||||
fn current_original_index(&self) -> Option<usize> {
|
||||
self.current.and_then(|c| self.order.get(c).copied())
|
||||
}
|
||||
|
||||
/// Индекс в ИСХОДНОМ порядке (как он был передан в set_tracks), а не в порядке
|
||||
/// воспроизведения — то есть именно тот индекс, который знает вызывающий код.
|
||||
pub fn current_index(&self) -> Option<usize> {
|
||||
self.current_original_index()
|
||||
}
|
||||
|
||||
pub fn current_item(&self) -> Option<&QueueItem> {
|
||||
self.current_original_index().map(|i| &self.original[i])
|
||||
}
|
||||
|
||||
/// Продвигает очередь вперёд. Возвращает false, если очередь закончилась
|
||||
/// (и повтор выключен) — в этом случае воспроизведение должно остановиться.
|
||||
pub fn advance(&mut self) -> bool {
|
||||
if self.original.is_empty() {
|
||||
return false;
|
||||
}
|
||||
match self.repeat {
|
||||
RepeatMode::Track => true, // остаёмся на месте, просто перезапускаем трек
|
||||
_ => {
|
||||
let next = self.current.map(|c| c + 1).unwrap_or(0);
|
||||
if next < self.order.len() {
|
||||
self.current = Some(next);
|
||||
true
|
||||
} else if self.repeat == RepeatMode::All {
|
||||
self.current = Some(0);
|
||||
true
|
||||
} else {
|
||||
self.current = None;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn previous(&mut self) -> bool {
|
||||
if self.original.is_empty() {
|
||||
return false;
|
||||
}
|
||||
match self.current {
|
||||
Some(0) | None => {
|
||||
if self.repeat == RepeatMode::All {
|
||||
self.current = Some(self.order.len() - 1);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Some(c) => {
|
||||
self.current = Some(c - 1);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn p(s: &str) -> QueueItem {
|
||||
QueueItem::whole_file(PathBuf::from(s))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_without_repeat_stops_at_end() {
|
||||
let mut q = Queue::default();
|
||||
q.set_tracks(vec![p("a"), p("b")], 0);
|
||||
assert_eq!(q.current_item().map(|i| &i.path), Some(&PathBuf::from("a")));
|
||||
assert!(q.advance());
|
||||
assert_eq!(q.current_item().map(|i| &i.path), Some(&PathBuf::from("b")));
|
||||
assert!(!q.advance());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_all_wraps_around() {
|
||||
let mut q = Queue::default();
|
||||
q.repeat = RepeatMode::All;
|
||||
q.set_tracks(vec![p("a"), p("b")], 0);
|
||||
q.advance();
|
||||
assert!(q.advance());
|
||||
assert_eq!(q.current_item().map(|i| &i.path), Some(&PathBuf::from("a")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_track_stays_put() {
|
||||
let mut q = Queue::default();
|
||||
q.repeat = RepeatMode::Track;
|
||||
q.set_tracks(vec![p("a"), p("b")], 0);
|
||||
assert!(q.advance());
|
||||
assert_eq!(q.current_item().map(|i| &i.path), Some(&PathBuf::from("a")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shuffle_preserves_current_track() {
|
||||
let mut q = Queue::default();
|
||||
q.set_tracks(vec![p("a"), p("b"), p("c"), p("d")], 2);
|
||||
assert_eq!(q.current_item().map(|i| &i.path), Some(&PathBuf::from("c")));
|
||||
q.set_shuffle(true);
|
||||
assert_eq!(q.current_item().map(|i| &i.path), Some(&PathBuf::from("c")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cue_virtual_tracks_share_path_but_advance_independently() {
|
||||
// Три "виртуальных" трека внутри одного физического файла (как cue-лист).
|
||||
let mut q = Queue::default();
|
||||
let file = PathBuf::from("album.flac");
|
||||
let items = vec![
|
||||
QueueItem {
|
||||
path: file.clone(),
|
||||
start: std::time::Duration::ZERO,
|
||||
end: Some(std::time::Duration::from_secs(180)),
|
||||
title_override: Some("Track One".into()),
|
||||
artist_override: None,
|
||||
replay_gain_db: None,
|
||||
},
|
||||
QueueItem {
|
||||
path: file.clone(),
|
||||
start: std::time::Duration::from_secs(180),
|
||||
end: Some(std::time::Duration::from_secs(360)),
|
||||
title_override: Some("Track Two".into()),
|
||||
artist_override: None,
|
||||
replay_gain_db: None,
|
||||
},
|
||||
QueueItem {
|
||||
path: file.clone(),
|
||||
start: std::time::Duration::from_secs(360),
|
||||
end: None,
|
||||
title_override: Some("Track Three".into()),
|
||||
artist_override: None,
|
||||
replay_gain_db: None,
|
||||
},
|
||||
];
|
||||
q.set_tracks(items, 0);
|
||||
assert_eq!(
|
||||
q.current_item().and_then(|i| i.title_override.clone()),
|
||||
Some("Track One".to_string())
|
||||
);
|
||||
assert!(q.advance());
|
||||
assert_eq!(
|
||||
q.current_item().and_then(|i| i.title_override.clone()),
|
||||
Some("Track Two".to_string())
|
||||
);
|
||||
assert!(q.advance());
|
||||
assert_eq!(
|
||||
q.current_item().and_then(|i| i.title_override.clone()),
|
||||
Some("Track Three".to_string())
|
||||
);
|
||||
assert!(!q.advance());
|
||||
}
|
||||
}
|
||||
85
crates/player/src/resample.rs
Normal file
85
crates/player/src/resample.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
//! Изменение скорости воспроизведения через ресемплинг: чтобы играть быстрее,
|
||||
//! мы "читаем" сэмплы как будто исходная частота дискретизации выше (и наоборот).
|
||||
//! Это простой подход "как на магнитофоне" — питч меняется вместе со скоростью.
|
||||
//! Более сложный time-stretch с сохранением питча можно добавить позже отдельным режимом.
|
||||
|
||||
use rubato::{SincFixedIn, SincInterpolationParameters, SincInterpolationType, VecResampler, WindowFunction};
|
||||
|
||||
pub struct SpeedResampler {
|
||||
channels: usize,
|
||||
resampler: SincFixedIn<f32>,
|
||||
chunk_size: usize,
|
||||
input_buf: Vec<Vec<f32>>,
|
||||
}
|
||||
|
||||
impl SpeedResampler {
|
||||
/// device_rate / track_rate — базовое соотношение без учёта скорости.
|
||||
pub fn new(channels: usize, base_ratio: f64, speed: f32) -> anyhow::Result<Self> {
|
||||
let chunk_size = 1024;
|
||||
let params = SincInterpolationParameters {
|
||||
sinc_len: 128,
|
||||
f_cutoff: 0.95,
|
||||
interpolation: SincInterpolationType::Linear,
|
||||
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,
|
||||
resampler,
|
||||
chunk_size,
|
||||
input_buf: vec![Vec::new(); channels],
|
||||
})
|
||||
}
|
||||
|
||||
/// Принимает interleaved f32, копит до chunk_size сэмплов на канал и прогоняет через
|
||||
/// ресемплер столько полных чанков, сколько накопилось. Возвращает готовый interleaved
|
||||
/// блок (может быть пустым, если данных пока не хватает на целый чанк).
|
||||
pub fn push_interleaved(&mut self, interleaved: &[f32]) -> anyhow::Result<Vec<f32>> {
|
||||
for frame in interleaved.chunks(self.channels) {
|
||||
for (ch, &s) in frame.iter().enumerate() {
|
||||
self.input_buf[ch].push(s);
|
||||
}
|
||||
}
|
||||
|
||||
let mut interleaved_out = Vec::new();
|
||||
while self.input_buf[0].len() >= self.chunk_size {
|
||||
let chunk: Vec<Vec<f32>> = self
|
||||
.input_buf
|
||||
.iter_mut()
|
||||
.map(|ch| ch.drain(0..self.chunk_size).collect())
|
||||
.collect();
|
||||
let out = self.resampler.process(&chunk, None)?;
|
||||
let frames = out[0].len();
|
||||
for i in 0..frames {
|
||||
for ch in 0..self.channels {
|
||||
interleaved_out.push(out[ch][i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(interleaved_out)
|
||||
}
|
||||
|
||||
/// Дренирует остаток входного буфера (например, при завершении трека), дополняя нулями
|
||||
/// до полного чанка, чтобы не потерять хвост.
|
||||
pub fn flush(&mut self) -> anyhow::Result<Vec<f32>> {
|
||||
if self.input_buf[0].is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let pad = self.chunk_size - self.input_buf[0].len();
|
||||
for ch in self.input_buf.iter_mut() {
|
||||
ch.extend(std::iter::repeat(0.0f32).take(pad));
|
||||
}
|
||||
let chunk: Vec<Vec<f32>> = self.input_buf.iter_mut().map(std::mem::take).collect();
|
||||
let out = self.resampler.process(&chunk, None)?;
|
||||
let mut interleaved_out = Vec::new();
|
||||
let frames = out[0].len();
|
||||
for i in 0..frames {
|
||||
for ch in 0..self.channels {
|
||||
interleaved_out.push(out[ch][i]);
|
||||
}
|
||||
}
|
||||
Ok(interleaved_out)
|
||||
}
|
||||
}
|
||||
141
crates/player/src/spectrum.rs
Normal file
141
crates/player/src/spectrum.rs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use realfft::num_complex::Complex;
|
||||
use realfft::{RealFftPlanner, RealToComplex};
|
||||
|
||||
/// Размер окна FFT — степень двойки, разумный компромисс между частотным и временным
|
||||
/// разрешением для визуализации (не для точного научного анализа).
|
||||
pub const FFT_SIZE: usize = 1024;
|
||||
/// Количество полос на выходе (логарифмическая шкала, как у обычных спектроанализаторов).
|
||||
pub const SPECTRUM_BANDS: usize = 20;
|
||||
|
||||
pub struct SpectrumAnalyzer {
|
||||
fft: Arc<dyn RealToComplex<f32>>,
|
||||
window: Vec<f32>,
|
||||
input_scratch: Vec<f32>,
|
||||
output_scratch: Vec<Complex<f32>>,
|
||||
}
|
||||
|
||||
impl SpectrumAnalyzer {
|
||||
pub fn new() -> Self {
|
||||
let mut planner = RealFftPlanner::<f32>::new();
|
||||
let fft = planner.plan_fft_forward(FFT_SIZE);
|
||||
// окно Ханна — сглаживает края окна, уменьшает "утечку" спектра между полосами
|
||||
let window: Vec<f32> = (0..FFT_SIZE)
|
||||
.map(|i| {
|
||||
0.5 - 0.5
|
||||
* (2.0 * std::f32::consts::PI * i as f32 / (FFT_SIZE as f32 - 1.0)).cos()
|
||||
})
|
||||
.collect();
|
||||
let input_scratch = fft.make_input_vec();
|
||||
let output_scratch = fft.make_output_vec();
|
||||
SpectrumAnalyzer {
|
||||
fft,
|
||||
window,
|
||||
input_scratch,
|
||||
output_scratch,
|
||||
}
|
||||
}
|
||||
|
||||
/// `samples` — ровно `FFT_SIZE` моно-сэмплов (последнее окно). Возвращает `SPECTRUM_BANDS`
|
||||
/// значений, сгруппированных по логарифмической шкале частот (как воспринимает слух),
|
||||
/// нормализованных примерно в диапазон 0..1.
|
||||
pub fn compute(&mut self, samples: &[f32], sample_rate: f32) -> [f32; SPECTRUM_BANDS] {
|
||||
debug_assert_eq!(samples.len(), FFT_SIZE);
|
||||
for i in 0..FFT_SIZE {
|
||||
self.input_scratch[i] = samples[i] * self.window[i];
|
||||
}
|
||||
if self
|
||||
.fft
|
||||
.process(&mut self.input_scratch, &mut self.output_scratch)
|
||||
.is_err()
|
||||
{
|
||||
return [0.0; SPECTRUM_BANDS];
|
||||
}
|
||||
|
||||
let magnitudes: Vec<f32> = self
|
||||
.output_scratch
|
||||
.iter()
|
||||
.map(|c| (c.re * c.re + c.im * c.im).sqrt())
|
||||
.collect();
|
||||
|
||||
bucket_log_scale(&magnitudes, sample_rate)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SpectrumAnalyzer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Группирует линейные бины FFT в `SPECTRUM_BANDS` логарифмических полос от ~20Гц до
|
||||
/// половины частоты дискретизации (или 20кГц, что меньше), и нормализует громким-сжатием
|
||||
/// (sqrt) для более "живой" визуальной динамики на тихих сигналах.
|
||||
fn bucket_log_scale(magnitudes: &[f32], sample_rate: f32) -> [f32; SPECTRUM_BANDS] {
|
||||
let min_freq: f32 = 20.0;
|
||||
let max_freq: f32 = (sample_rate / 2.0).min(20_000.0);
|
||||
let log_min = min_freq.ln();
|
||||
let log_max = max_freq.ln();
|
||||
let bin_hz = sample_rate / FFT_SIZE as f32;
|
||||
|
||||
let mut bands = [0.0f32; SPECTRUM_BANDS];
|
||||
for (b, band) in bands.iter_mut().enumerate() {
|
||||
let f_lo = (log_min + (log_max - log_min) * b as f32 / SPECTRUM_BANDS as f32).exp();
|
||||
let f_hi = (log_min + (log_max - log_min) * (b + 1) as f32 / SPECTRUM_BANDS as f32).exp();
|
||||
let bin_lo = ((f_lo / bin_hz).round() as usize).max(1);
|
||||
let bin_hi = ((f_hi / bin_hz).round() as usize)
|
||||
.min(magnitudes.len().saturating_sub(1))
|
||||
.max(bin_lo + 1);
|
||||
|
||||
let slice = &magnitudes[bin_lo.min(magnitudes.len())..bin_hi.min(magnitudes.len())];
|
||||
let avg = if slice.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
slice.iter().sum::<f32>() / slice.len() as f32
|
||||
};
|
||||
// нормализация: сырые магнитуды FFT могут быть большими числами без верхней границы
|
||||
// конкретного смысла для отображения — берём корень и делим на эмпирическую константу,
|
||||
// чтобб типичная громкая музыка укладывалась примерно в 0..1
|
||||
*band = (avg.sqrt() / 12.0).clamp(0.0, 1.0);
|
||||
}
|
||||
bands
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sine_wave_peaks_in_expected_band() {
|
||||
let sample_rate = 44100.0;
|
||||
// тестовый тон 1000 Гц — должен дать пик в средних полосах, а не в самых низких/высоких
|
||||
let freq = 1000.0f32;
|
||||
let samples: Vec<f32> = (0..FFT_SIZE)
|
||||
.map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate).sin())
|
||||
.collect();
|
||||
|
||||
let mut analyzer = SpectrumAnalyzer::new();
|
||||
let bands = analyzer.compute(&samples, sample_rate);
|
||||
|
||||
let (peak_idx, peak_val) = bands
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||
.unwrap();
|
||||
|
||||
// не самая первая (бас) и не самая последняя (высокие) полоса
|
||||
assert!(peak_idx > 2 && peak_idx < SPECTRUM_BANDS - 2, "peak at {peak_idx}");
|
||||
assert!(*peak_val > 0.05, "peak too quiet: {peak_val}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_gives_near_zero_bands() {
|
||||
let samples = vec![0.0f32; FFT_SIZE];
|
||||
let mut analyzer = SpectrumAnalyzer::new();
|
||||
let bands = analyzer.compute(&samples, 44100.0);
|
||||
for b in bands {
|
||||
assert!(b < 0.01, "expected near-zero for silence, got {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
178
crates/player/src/types.rs
Normal file
178
crates/player/src/types.rs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Режим повтора очереди.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum RepeatMode {
|
||||
#[default]
|
||||
Off,
|
||||
Track,
|
||||
All,
|
||||
}
|
||||
|
||||
impl RepeatMode {
|
||||
pub fn cycle(self) -> Self {
|
||||
match self {
|
||||
RepeatMode::Off => RepeatMode::All,
|
||||
RepeatMode::All => RepeatMode::Track,
|
||||
RepeatMode::Track => RepeatMode::Off,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Допустимые скорости воспроизведения (тайпраст-стайл: питч меняется вместе со скоростью).
|
||||
pub const SPEED_STEPS: [f32; 7] = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Speed(pub f32);
|
||||
|
||||
impl Default for Speed {
|
||||
fn default() -> Self {
|
||||
Speed(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Speed {
|
||||
pub fn clamp_to_steps(value: f32) -> f32 {
|
||||
SPEED_STEPS
|
||||
.iter()
|
||||
.copied()
|
||||
.min_by(|a, b| {
|
||||
(a - value)
|
||||
.abs()
|
||||
.partial_cmp(&(b - value).abs())
|
||||
.unwrap()
|
||||
})
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
pub fn next_up(self) -> Self {
|
||||
let idx = SPEED_STEPS
|
||||
.iter()
|
||||
.position(|s| (*s - self.0).abs() < f32::EPSILON)
|
||||
.unwrap_or(3);
|
||||
Speed(SPEED_STEPS[(idx + 1).min(SPEED_STEPS.len() - 1)])
|
||||
}
|
||||
|
||||
pub fn next_down(self) -> Self {
|
||||
let idx = SPEED_STEPS
|
||||
.iter()
|
||||
.position(|s| (*s - self.0).abs() < f32::EPSILON)
|
||||
.unwrap_or(3);
|
||||
Speed(SPEED_STEPS[idx.saturating_sub(1)])
|
||||
}
|
||||
}
|
||||
|
||||
/// Количество полос эквалайзера.
|
||||
pub const EQ_BANDS: usize = 8;
|
||||
/// Центральные частоты полос (Гц), классическая графическая раскладка.
|
||||
pub const EQ_FREQS: [f32; EQ_BANDS] = [60.0, 150.0, 400.0, 1000.0, 2400.0, 6000.0, 12000.0, 16000.0];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum PlaybackStatus {
|
||||
Stopped,
|
||||
Playing,
|
||||
Paused,
|
||||
}
|
||||
|
||||
/// Элемент очереди воспроизведения. Обычно это просто "весь файл", но для cue-листов
|
||||
/// (несколько логических треков внутри одного физического файла) start/end задают диапазон,
|
||||
/// а title/artist_override — то, что показывать вместо тегов самого файла.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct QueueItem {
|
||||
pub path: PathBuf,
|
||||
pub start: Duration,
|
||||
pub end: Option<Duration>,
|
||||
pub title_override: Option<String>,
|
||||
pub artist_override: Option<String>,
|
||||
/// Значение тега ReplayGain (дБ), если есть в файле — приоритет над AGC-левеллером.
|
||||
pub replay_gain_db: Option<f32>,
|
||||
}
|
||||
|
||||
impl QueueItem {
|
||||
pub fn whole_file(path: PathBuf) -> Self {
|
||||
QueueItem {
|
||||
path,
|
||||
start: Duration::ZERO,
|
||||
end: None,
|
||||
title_override: None,
|
||||
artist_override: None,
|
||||
replay_gain_db: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Что именно поставить в очередь при открытии трека напрямую.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum OpenMode {
|
||||
/// Только этот трек.
|
||||
SingleTrack,
|
||||
/// Весь альбом (все треки из той же папки/альбома), отсортированный по номеру трека.
|
||||
WholeAlbum,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PlayerCommand {
|
||||
/// Открыть файл напрямую: либо один трек, либо весь альбом вокруг него.
|
||||
OpenPath(PathBuf, OpenMode),
|
||||
/// Заменить очередь целиком (например, при выборе плейлиста) и начать играть с индекса.
|
||||
SetQueue(Vec<QueueItem>, usize),
|
||||
/// Добавить трек в конец текущей очереди.
|
||||
Enqueue(PathBuf),
|
||||
PlayPause,
|
||||
Stop,
|
||||
Next,
|
||||
Previous,
|
||||
/// Позиция отсчитывается от начала ТЕКУЩЕГО трека (а не файла) — так, как её видит
|
||||
/// пользователь на прогресс-баре. Для cue-треков это не то же самое, что абсолютная
|
||||
/// позиция в физическом файле — движок сам делает пересчёт.
|
||||
Seek(Duration),
|
||||
SetSpeed(f32),
|
||||
ToggleShuffle,
|
||||
CycleRepeat,
|
||||
SetEqBand(usize, f32),
|
||||
ResetEq,
|
||||
/// Играть интернет-радио по URL. Название нужно только для отображения в UI/тегах.
|
||||
PlayRadio { name: String, url: String },
|
||||
ToggleMute,
|
||||
/// Плавное затухание громкости в последние несколько секунд трека.
|
||||
ToggleFadeOut,
|
||||
/// Выравнивание громкости между треками: тег ReplayGain в приоритете,
|
||||
/// адаптивный левеллер (AGC) как fallback для файлов без тега.
|
||||
ToggleLoudnessNorm,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrackInfo {
|
||||
pub path: PathBuf,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: String,
|
||||
pub duration: Option<Duration>,
|
||||
pub cover: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PlayerEvent {
|
||||
TrackChanged(TrackInfo),
|
||||
Position(Duration),
|
||||
StatusChanged(PlaybackStatus),
|
||||
SpeedChanged(f32),
|
||||
ShuffleChanged(bool),
|
||||
RepeatChanged(RepeatMode),
|
||||
EqChanged([f32; EQ_BANDS]),
|
||||
MuteChanged(bool),
|
||||
/// Текст "сейчас играет", распарсенный из ICY-метаданных потока (если станция их отдаёт).
|
||||
RadioMetadata(String),
|
||||
/// Индекс (в исходном порядке, как он был передан в SetQueue) элемента очереди,
|
||||
/// который сейчас играет. Шлётся вместе с TrackChanged — так UI однозначно знает,
|
||||
/// какой пункт своей локальной копии очереди подсвечивать, даже если несколько
|
||||
/// элементов указывают на один физический файл (cue-листы).
|
||||
QueueIndexChanged(usize),
|
||||
/// Текущий спектр (логарифмические полосы, 0..1), для визуализатора в UI.
|
||||
Spectrum(Vec<f32>),
|
||||
FadeOutChanged(bool),
|
||||
LoudnessNormChanged(bool),
|
||||
QueueFinished,
|
||||
Error(String),
|
||||
}
|
||||
14
crates/tui/Cargo.toml
Normal file
14
crates/tui/Cargo.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
[package]
|
||||
name = "tui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
player = { path = "../player" }
|
||||
library = { path = "../library" }
|
||||
ratatui = "0.28"
|
||||
crossterm = "0.28"
|
||||
ratatui-image = { version = "1", default-features = false, features = ["crossterm", "rustix"] }
|
||||
image = { version = "=0.25.1", default-features = false, features = ["jpeg", "png", "gif"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||
1037
crates/tui/src/app.rs
Normal file
1037
crates/tui/src/app.rs
Normal file
File diff suppressed because it is too large
Load diff
694
crates/tui/src/lib.rs
Normal file
694
crates/tui/src/lib.rs
Normal file
|
|
@ -0,0 +1,694 @@
|
|||
mod app;
|
||||
mod ui;
|
||||
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
use app::{App, Focus, LibraryView, Tab};
|
||||
|
||||
const TICK_RATE: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Обратное отображение стандартной раскладки ЙЦУКЕН на физические позиции QWERTY.
|
||||
/// Применяется ТОЛЬКО к клавишам-командам (навигация/транспорт), а не к вводу текста —
|
||||
/// так что искать/называть плейлисты и станции кириллицей по-прежнему можно как обычно,
|
||||
/// а горячие клавиши при этом не зависят от того, какая раскладка сейчас активна.
|
||||
fn normalize_layout(c: char) -> char {
|
||||
match c {
|
||||
'й' => 'q', 'ц' => 'w', 'у' => 'e', 'к' => 'r', 'е' => 't',
|
||||
'н' => 'y', 'г' => 'u', 'ш' => 'i', 'щ' => 'o', 'з' => 'p',
|
||||
'х' => '[', 'ъ' => ']',
|
||||
'ф' => 'a', 'ы' => 's', 'в' => 'd', 'а' => 'f', 'п' => 'g',
|
||||
'р' => 'h', 'о' => 'j', 'л' => 'k', 'д' => 'l',
|
||||
'я' => 'z', 'ч' => 'x', 'с' => 'c', 'м' => 'v', 'и' => 'b',
|
||||
'т' => 'n', 'ь' => 'm',
|
||||
'.' => '/', // физическая клавиша "/" на RU-раскладке без шифта даёт "."
|
||||
|
||||
'Й' => 'Q', 'Ц' => 'W', 'У' => 'E', 'К' => 'R', 'Е' => 'T',
|
||||
'Н' => 'Y', 'Г' => 'U', 'Ш' => 'I', 'Щ' => 'O', 'З' => 'P',
|
||||
'Ф' => 'A', 'Ы' => 'S', 'В' => 'D', 'А' => 'F', 'П' => 'G',
|
||||
'Р' => 'H', 'О' => 'J', 'Л' => 'K', 'Д' => 'L',
|
||||
'Я' => 'Z', 'Ч' => 'X', 'С' => 'C', 'М' => 'V', 'И' => 'B',
|
||||
'Т' => 'N', 'Ь' => 'M',
|
||||
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(root: PathBuf) -> anyhow::Result<()> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let mut app = App::new(root)?;
|
||||
let result = event_loop(&mut terminal, &mut app);
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn event_loop<B: ratatui::backend::Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
app: &mut App,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut last_tick = Instant::now();
|
||||
|
||||
loop {
|
||||
terminal.draw(|f| ui::draw(f, app))?;
|
||||
|
||||
let timeout = TICK_RATE.saturating_sub(last_tick.elapsed());
|
||||
if event::poll(timeout)? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.kind == KeyEventKind::Press {
|
||||
handle_key(app, key.code, key.modifiers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if last_tick.elapsed() >= TICK_RATE {
|
||||
app.drain_player_events();
|
||||
app.poll_new_releases();
|
||||
app.poll_station_search();
|
||||
last_tick = Instant::now();
|
||||
}
|
||||
|
||||
if app.should_quit {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) {
|
||||
if app.fullscreen {
|
||||
handle_fullscreen_key(app, code);
|
||||
return;
|
||||
}
|
||||
match app.focus {
|
||||
Focus::FirstRunPrompt => handle_first_run_prompt_key(app, code),
|
||||
Focus::Search => handle_search_key(app, code),
|
||||
Focus::NewPlaylist => handle_new_playlist_key(app, code),
|
||||
Focus::NewStationName => handle_new_station_name_key(app, code),
|
||||
Focus::NewStationUrl => handle_new_station_url_key(app, code),
|
||||
Focus::StationSearch => handle_station_search_key(app, code),
|
||||
Focus::SmbServer => handle_smb_text_key(app, code, |a| &mut a.smb_server, Focus::SmbShare),
|
||||
Focus::SmbShare => handle_smb_text_key(app, code, |a| &mut a.smb_share, Focus::SmbUsername),
|
||||
Focus::SmbUsername => handle_smb_text_key(app, code, |a| &mut a.smb_username, Focus::SmbPassword),
|
||||
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::Eq => handle_eq_key(app, code),
|
||||
Focus::Normal => handle_normal_key(app, code, modifiers),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_fullscreen_key(app: &mut App, code: KeyCode) {
|
||||
let code = match code {
|
||||
KeyCode::Char(c) => KeyCode::Char(normalize_layout(c)),
|
||||
other => other,
|
||||
};
|
||||
match code {
|
||||
KeyCode::Esc | KeyCode::Char('z') => app.toggle_fullscreen(),
|
||||
KeyCode::Char('v') => app.toggle_right_panel_view(),
|
||||
KeyCode::Char('q') => app.should_quit = true,
|
||||
KeyCode::Char(' ') => {
|
||||
app.player.play_pause();
|
||||
}
|
||||
KeyCode::Char('m') => {
|
||||
app.player.toggle_mute();
|
||||
}
|
||||
KeyCode::Char('N') => {
|
||||
app.player.next();
|
||||
}
|
||||
KeyCode::Char('P') => {
|
||||
app.player.previous();
|
||||
}
|
||||
KeyCode::Right => app.seek_relative(5),
|
||||
KeyCode::Left => app.seek_relative(-5),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_first_run_prompt_key(app: &mut App, code: KeyCode) {
|
||||
let code = match code {
|
||||
KeyCode::Char(c) => KeyCode::Char(normalize_layout(c)),
|
||||
other => other,
|
||||
};
|
||||
match code {
|
||||
KeyCode::Char('y') | KeyCode::Char('Y') => app.answer_first_run_prompt(true),
|
||||
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => app.answer_first_run_prompt(false),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_search_key(app: &mut App, code: KeyCode) {
|
||||
match code {
|
||||
KeyCode::Esc => {
|
||||
app.search_query.clear();
|
||||
app.rebuild_library_view();
|
||||
app.focus = Focus::Normal;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
app.focus = Focus::Normal;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.search_query.pop();
|
||||
app.rebuild_library_view();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
app.search_query.push(c);
|
||||
app.rebuild_library_view();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_new_playlist_key(app: &mut App, code: KeyCode) {
|
||||
match code {
|
||||
KeyCode::Esc => {
|
||||
app.new_playlist_name.clear();
|
||||
app.focus = Focus::Normal;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let name = app.new_playlist_name.clone();
|
||||
app.create_playlist(&name);
|
||||
app.new_playlist_name.clear();
|
||||
app.focus = Focus::Normal;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.new_playlist_name.pop();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
app.new_playlist_name.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_new_station_name_key(app: &mut App, code: KeyCode) {
|
||||
match code {
|
||||
KeyCode::Esc => {
|
||||
app.new_station_name.clear();
|
||||
app.focus = Focus::Normal;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !app.new_station_name.trim().is_empty() {
|
||||
app.focus = Focus::NewStationUrl;
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.new_station_name.pop();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
app.new_station_name.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_new_station_url_key(app: &mut App, code: KeyCode) {
|
||||
match code {
|
||||
KeyCode::Esc => {
|
||||
app.new_station_name.clear();
|
||||
app.new_station_url.clear();
|
||||
app.focus = Focus::Normal;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let name = app.new_station_name.clone();
|
||||
let url = app.new_station_url.clone();
|
||||
app.add_station(&name, &url);
|
||||
app.new_station_name.clear();
|
||||
app.new_station_url.clear();
|
||||
app.focus = Focus::Normal;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.new_station_url.pop();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
app.new_station_url.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_station_search_key(app: &mut App, code: KeyCode) {
|
||||
match code {
|
||||
KeyCode::Esc => {
|
||||
app.station_query.clear();
|
||||
app.focus = Focus::Normal;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let query = app.station_query.clone();
|
||||
app.search_stations_online(&query);
|
||||
app.focus = Focus::Normal;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.station_query.pop();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
app.station_query.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_smb_text_key(app: &mut App, code: KeyCode, field: fn(&mut App) -> &mut String, next: Focus) {
|
||||
match code {
|
||||
KeyCode::Esc => {
|
||||
app.smb_server.clear();
|
||||
app.smb_share.clear();
|
||||
app.smb_username.clear();
|
||||
app.smb_password.clear();
|
||||
app.focus = Focus::Normal;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
app.focus = next;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
field(app).pop();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
field(app).push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_library_path_input_key(app: &mut App, code: KeyCode) {
|
||||
match code {
|
||||
KeyCode::Esc => {
|
||||
app.library_path_input.clear();
|
||||
app.focus = Focus::Normal;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let path = app.library_path_input.clone();
|
||||
app.add_library_folder(&path);
|
||||
app.library_path_input.clear();
|
||||
app.focus = Focus::Normal;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.library_path_input.pop();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
app.library_path_input.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_new_release_artist_input_key(app: &mut App, code: KeyCode) {
|
||||
match code {
|
||||
KeyCode::Esc => {
|
||||
app.new_release_artist_input.clear();
|
||||
app.focus = Focus::Normal;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let artist = app.new_release_artist_input.clone();
|
||||
app.check_manual_artist_new_releases(&artist);
|
||||
app.new_release_artist_input.clear();
|
||||
app.focus = Focus::Normal;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.new_release_artist_input.pop();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
app.new_release_artist_input.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_smb_password_key(app: &mut App, code: KeyCode) {
|
||||
match code {
|
||||
KeyCode::Esc => {
|
||||
app.smb_server.clear();
|
||||
app.smb_share.clear();
|
||||
app.smb_username.clear();
|
||||
app.smb_password.clear();
|
||||
app.focus = Focus::Normal;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
app.connect_smb_share();
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.smb_password.pop();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
app.smb_password.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_eq_key(app: &mut App, code: KeyCode) {
|
||||
let code = match code {
|
||||
KeyCode::Char(c) => KeyCode::Char(normalize_layout(c)),
|
||||
other => other,
|
||||
};
|
||||
match code {
|
||||
KeyCode::Esc | KeyCode::Char('e') => app.focus = Focus::Normal,
|
||||
KeyCode::Left => {
|
||||
app.eq_selected = app.eq_selected.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Right => {
|
||||
app.eq_selected = (app.eq_selected + 1).min(player::EQ_BANDS - 1);
|
||||
}
|
||||
KeyCode::Up => app.adjust_eq(1.0),
|
||||
KeyCode::Down => app.adjust_eq(-1.0),
|
||||
KeyCode::Char('0') => {
|
||||
let band = app.eq_selected;
|
||||
let delta = -app.eq_gains[band];
|
||||
app.adjust_eq(delta);
|
||||
}
|
||||
KeyCode::Char('R') => {
|
||||
app.player.reset_eq();
|
||||
app.eq_gains = [0.0; player::EQ_BANDS];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_normal_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) {
|
||||
let code = match code {
|
||||
KeyCode::Char(c) => KeyCode::Char(normalize_layout(c)),
|
||||
other => other,
|
||||
};
|
||||
// Глобальные транспортные клавиши, доступные на любой вкладке.
|
||||
match code {
|
||||
KeyCode::Char('q') => {
|
||||
app.should_quit = true;
|
||||
return;
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
app.tab = app.tab.next();
|
||||
return;
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
app.player.play_pause();
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('n') if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
app.player.next();
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('N') => {
|
||||
app.player.next();
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('P') => {
|
||||
app.player.previous();
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('s') => {
|
||||
app.shuffle = !app.shuffle;
|
||||
app.player.toggle_shuffle();
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
app.player.cycle_repeat();
|
||||
return;
|
||||
}
|
||||
KeyCode::Char(']') => {
|
||||
app.speed = app.speed.next_up();
|
||||
app.player.set_speed(app.speed);
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('[') => {
|
||||
app.speed = app.speed.next_down();
|
||||
app.player.set_speed(app.speed);
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('e') => {
|
||||
app.focus = Focus::Eq;
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('m') => {
|
||||
app.player.toggle_mute();
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('h') => {
|
||||
app.toggle_history();
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('v') => {
|
||||
app.toggle_right_panel_view();
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('z') => {
|
||||
app.toggle_fullscreen();
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('F') => {
|
||||
app.player.toggle_fade_out();
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('G') => {
|
||||
app.player.toggle_loudness_norm();
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('I') => {
|
||||
app.cycle_cover_protocol();
|
||||
return;
|
||||
}
|
||||
KeyCode::Right => {
|
||||
app.seek_relative(5);
|
||||
return;
|
||||
}
|
||||
KeyCode::Left => {
|
||||
app.seek_relative(-5);
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match app.tab {
|
||||
Tab::Library => handle_library_key(app, code),
|
||||
Tab::Playlists => handle_playlists_key(app, code),
|
||||
Tab::Queue => handle_queue_key(app, code),
|
||||
Tab::Radio => handle_radio_key(app, code),
|
||||
Tab::Stats => handle_stats_key(app, code),
|
||||
Tab::NewReleases => handle_new_releases_key(app, code),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_library_key(app: &mut App, code: KeyCode) {
|
||||
match code {
|
||||
KeyCode::Char('/') => {
|
||||
app.focus = Focus::Search;
|
||||
}
|
||||
KeyCode::Char('a') => {
|
||||
app.add_selected_to_playlist();
|
||||
}
|
||||
KeyCode::Char('c') => {
|
||||
app.focus = Focus::SmbServer;
|
||||
}
|
||||
KeyCode::Char('L') => {
|
||||
app.focus = Focus::LibraryPathInput;
|
||||
}
|
||||
KeyCode::Char('C') => {
|
||||
app.clear_library();
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => move_selection(app, 1),
|
||||
KeyCode::Up | KeyCode::Char('k') => move_selection(app, -1),
|
||||
KeyCode::Esc => {
|
||||
if app.expanded_album.is_some() {
|
||||
app.expanded_album = None;
|
||||
app.library_selected = 0;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => match &app.library_view {
|
||||
LibraryView::Albums(rows) => {
|
||||
if app.expanded_album.is_some() {
|
||||
app.play_expanded_track(app.library_selected);
|
||||
} else if !rows.is_empty() {
|
||||
app.expanded_album = Some(app.library_selected);
|
||||
app.library_selected = 0;
|
||||
}
|
||||
}
|
||||
LibraryView::Search(_) => {
|
||||
app.play_album_from_selected();
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn move_selection(app: &mut App, delta: i32) {
|
||||
let len = match &app.library_view {
|
||||
LibraryView::Albums(rows) => {
|
||||
if let Some(idx) = app.expanded_album {
|
||||
rows.get(idx).map(|r| r.tracks.len()).unwrap_or(0)
|
||||
} else {
|
||||
rows.len()
|
||||
}
|
||||
}
|
||||
LibraryView::Search(rows) => rows.len(),
|
||||
};
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
let current = app.library_selected as i32;
|
||||
let new = (current + delta).rem_euclid(len as i32);
|
||||
app.library_selected = new as usize;
|
||||
}
|
||||
|
||||
fn handle_playlists_key(app: &mut App, code: KeyCode) {
|
||||
match code {
|
||||
KeyCode::Char('n') => {
|
||||
app.focus = Focus::NewPlaylist;
|
||||
}
|
||||
KeyCode::Char('d') if app.open_playlist.is_some() => {
|
||||
app.playlist_delete_selected();
|
||||
}
|
||||
KeyCode::Char('J') if app.open_playlist.is_some() => {
|
||||
app.playlist_move_selected(1);
|
||||
}
|
||||
KeyCode::Char('K') if app.open_playlist.is_some() => {
|
||||
app.playlist_move_selected(-1);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if app.open_playlist.is_some() {
|
||||
let len = app.open_playlist.as_ref().map(|p| p.tracks.len()).unwrap_or(0);
|
||||
if len > 0 {
|
||||
app.playlist_track_selected = (app.playlist_track_selected + 1) % len;
|
||||
}
|
||||
} else if !app.playlist_names.is_empty() {
|
||||
app.playlists_selected = (app.playlists_selected + 1) % app.playlist_names.len();
|
||||
}
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if app.open_playlist.is_some() {
|
||||
let len = app.open_playlist.as_ref().map(|p| p.tracks.len()).unwrap_or(0);
|
||||
if len > 0 {
|
||||
app.playlist_track_selected =
|
||||
(app.playlist_track_selected + len - 1) % len;
|
||||
}
|
||||
} else if !app.playlist_names.is_empty() {
|
||||
let len = app.playlist_names.len();
|
||||
app.playlists_selected = (app.playlists_selected + len - 1) % len;
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
app.open_playlist = None;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if app.open_playlist.is_some() {
|
||||
app.play_open_playlist_from(app.playlist_track_selected);
|
||||
} else if let Some(name) = app.playlist_names.get(app.playlists_selected).cloned() {
|
||||
if let Ok(pl) = app.playlist_store.load(&name) {
|
||||
app.open_playlist = Some(pl);
|
||||
app.playlist_track_selected = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_queue_key(app: &mut App, code: KeyCode) {
|
||||
match code {
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if !app.current_queue.is_empty() {
|
||||
app.queue_selected = (app.queue_selected + 1) % app.current_queue.len();
|
||||
}
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if !app.current_queue.is_empty() {
|
||||
let len = app.current_queue.len();
|
||||
app.queue_selected = (app.queue_selected + len - 1) % len;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
app.player.set_queue(app.current_queue.clone(), app.queue_selected);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_stats_key(app: &mut App, code: KeyCode) {
|
||||
match code {
|
||||
KeyCode::Char('p') => app.cycle_stats_period(),
|
||||
KeyCode::Char('x') => app.request_reset_stats(),
|
||||
_ => app.cancel_reset_arm(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_new_releases_key(app: &mut App, code: KeyCode) {
|
||||
match code {
|
||||
KeyCode::Char('p') => app.cycle_new_releases_period(),
|
||||
KeyCode::Char('f') => app.start_new_releases_fetch(),
|
||||
KeyCode::Char('a') => {
|
||||
app.focus = Focus::NewReleaseArtistInput;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_radio_key(app: &mut App, code: KeyCode) {
|
||||
if !app.online_results.is_empty() {
|
||||
handle_online_results_key(app, code);
|
||||
return;
|
||||
}
|
||||
match code {
|
||||
KeyCode::Char('u') => {
|
||||
app.focus = Focus::NewStationName;
|
||||
}
|
||||
KeyCode::Char('o') => {
|
||||
app.focus = Focus::StationSearch;
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
app.remove_selected_station();
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if !app.stations.is_empty() {
|
||||
app.radio_selected = (app.radio_selected + 1) % app.stations.len();
|
||||
}
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if !app.stations.is_empty() {
|
||||
let len = app.stations.len();
|
||||
app.radio_selected = (app.radio_selected + len - 1) % len;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
app.play_selected_station();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_online_results_key(app: &mut App, code: KeyCode) {
|
||||
match code {
|
||||
KeyCode::Esc => {
|
||||
app.close_online_results();
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if !app.online_results.is_empty() {
|
||||
app.online_selected = (app.online_selected + 1) % app.online_results.len();
|
||||
}
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if !app.online_results.is_empty() {
|
||||
let len = app.online_results.len();
|
||||
app.online_selected = (app.online_selected + len - 1) % len;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
app.play_online_result();
|
||||
}
|
||||
KeyCode::Char('a') => {
|
||||
app.add_online_result_to_custom();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
761
crates/tui/src/ui.rs
Normal file
761
crates/tui/src/ui.rs
Normal file
|
|
@ -0,0 +1,761 @@
|
|||
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Bar, BarChart, BarGroup, Block, Borders, Gauge, List, ListItem, Paragraph, Tabs};
|
||||
use ratatui::Frame;
|
||||
use ratatui_image::StatefulImage;
|
||||
|
||||
use crate::app::{protocol_to_str, App, Focus, LibraryView, RightPanelView, Tab};
|
||||
|
||||
pub fn draw(frame: &mut Frame, app: &mut App) {
|
||||
if app.fullscreen {
|
||||
draw_fullscreen(frame, app);
|
||||
return;
|
||||
}
|
||||
|
||||
let area = frame.area();
|
||||
const MIN_WIDTH: u16 = 70;
|
||||
const MIN_HEIGHT: u16 = 18;
|
||||
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
|
||||
draw_too_small(frame, area, MIN_WIDTH, MIN_HEIGHT);
|
||||
return;
|
||||
}
|
||||
|
||||
let root = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
draw_tabs(frame, app, root[0]);
|
||||
|
||||
let body = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
|
||||
.split(root[1]);
|
||||
|
||||
match app.tab {
|
||||
Tab::Library => draw_library(frame, app, body[0]),
|
||||
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::Stats => draw_stats(frame, app, body[0]),
|
||||
Tab::NewReleases => draw_new_releases(frame, app, body[0]),
|
||||
}
|
||||
|
||||
draw_now_playing(frame, app, body[1]);
|
||||
draw_status(frame, app, root[2]);
|
||||
|
||||
if app.focus == Focus::FirstRunPrompt {
|
||||
draw_first_run_prompt(frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// Небольшой прямоугольник по центру экрана заданного размера (в колонках/строках).
|
||||
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
|
||||
let width = width.min(area.width);
|
||||
let height = height.min(area.height);
|
||||
let x = area.x + (area.width.saturating_sub(width)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(height)) / 2;
|
||||
Rect::new(x, y, width, height)
|
||||
}
|
||||
|
||||
fn draw_first_run_prompt(frame: &mut Frame) {
|
||||
let area = centered_rect(60, 9, frame.area());
|
||||
frame.render_widget(ratatui::widgets::Clear, area);
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Welcome to nsm ")
|
||||
.style(Style::default().bg(Color::Black));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let lines = vec![
|
||||
Line::from(""),
|
||||
Line::from("Remember what you listen to?"),
|
||||
Line::from("This lets you see listening stats later (day/week/month)."),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
" Y — yes, track it N — no, don't track",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from("(You can change this anytime with 'h')"),
|
||||
];
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines).alignment(Alignment::Center),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_fullscreen(frame: &mut Frame, app: &mut App) {
|
||||
let root = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(0), Constraint::Length(1)])
|
||||
.split(frame.area());
|
||||
|
||||
let title = match app.right_panel_view {
|
||||
RightPanelView::Cover => " Cover — fullscreen ",
|
||||
RightPanelView::Spectrum => " Spectrum — fullscreen ",
|
||||
};
|
||||
let block = Block::default().borders(Borders::ALL).title(title);
|
||||
let inner = block.inner(root[0]);
|
||||
frame.render_widget(block, root[0]);
|
||||
|
||||
match app.right_panel_view {
|
||||
RightPanelView::Cover => {
|
||||
if let Some(protocol) = app.cover_protocol.as_mut() {
|
||||
let image = StatefulImage::new(None);
|
||||
frame.render_stateful_widget(image, inner, protocol);
|
||||
} else {
|
||||
frame.render_widget(
|
||||
Paragraph::new("(no cover)").alignment(Alignment::Center),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
}
|
||||
RightPanelView::Spectrum => draw_spectrum_bars(frame, &app.spectrum, inner),
|
||||
}
|
||||
|
||||
let now_playing_line = app
|
||||
.now_playing
|
||||
.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)"
|
||||
);
|
||||
frame.render_widget(Paragraph::new(hint), root[1]);
|
||||
}
|
||||
|
||||
fn draw_too_small(frame: &mut Frame, area: Rect, min_width: u16, min_height: u16) {
|
||||
let msg = format!(
|
||||
"Terminal too small ({}x{}).\nResize to at least {}x{}.",
|
||||
area.width, area.height, min_width, min_height
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(msg).alignment(Alignment::Center),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_tabs(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let titles = [
|
||||
Tab::Library,
|
||||
Tab::Playlists,
|
||||
Tab::Queue,
|
||||
Tab::Radio,
|
||||
Tab::Stats,
|
||||
Tab::NewReleases,
|
||||
]
|
||||
.iter()
|
||||
.map(|t| t.title())
|
||||
.collect::<Vec<_>>();
|
||||
let selected = match app.tab {
|
||||
Tab::Library => 0,
|
||||
Tab::Playlists => 1,
|
||||
Tab::Queue => 2,
|
||||
Tab::Radio => 3,
|
||||
Tab::Stats => 4,
|
||||
Tab::NewReleases => 5,
|
||||
};
|
||||
let tabs = Tabs::new(titles)
|
||||
.block(Block::default().borders(Borders::ALL).title(" nsm "))
|
||||
.select(selected)
|
||||
.highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
|
||||
frame.render_widget(tabs, area);
|
||||
}
|
||||
|
||||
fn draw_library(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(0)])
|
||||
.split(area);
|
||||
|
||||
let search_style = if app.focus == Focus::Search {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let search = Paragraph::new(format!("/{}", app.search_query))
|
||||
.style(search_style)
|
||||
.block(Block::default().borders(Borders::ALL).title(" Search "));
|
||||
frame.render_widget(search, chunks[0]);
|
||||
|
||||
let items: Vec<ListItem> = match &app.library_view {
|
||||
LibraryView::Albums(rows) => {
|
||||
if let Some(album_idx) = app.expanded_album {
|
||||
if let Some(row) = rows.get(album_idx) {
|
||||
row.tracks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| {
|
||||
let name = item.title_override.clone().unwrap_or_else(|| {
|
||||
item.path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("?")
|
||||
.to_string()
|
||||
});
|
||||
ListItem::new(format!(" {:>2}. {}", i + 1, name))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
} else {
|
||||
rows.iter()
|
||||
.map(|r| {
|
||||
ListItem::new(format!(
|
||||
"{} — {} ({} tracks)",
|
||||
r.artist,
|
||||
r.album,
|
||||
r.tracks.len()
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
LibraryView::Search(rows) => rows
|
||||
.iter()
|
||||
.map(|r| ListItem::new(format!("{} — {} [{}]", r.artist, r.title, r.album)))
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let title = if app.expanded_album.is_some() {
|
||||
" Album (Enter: play from here, Esc: back) "
|
||||
} else {
|
||||
" Albums (Enter: expand/play, /: search, a: add to playlist, c: connect SMB, L: add folder, C: clear library) "
|
||||
};
|
||||
|
||||
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.library_selected));
|
||||
frame.render_stateful_widget(list, chunks[1], &mut state);
|
||||
}
|
||||
|
||||
fn draw_playlists(frame: &mut Frame, app: &App, area: Rect) {
|
||||
if let Some(pl) = &app.open_playlist {
|
||||
let items: Vec<ListItem> = pl
|
||||
.tracks
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("?");
|
||||
ListItem::new(name.to_string())
|
||||
})
|
||||
.collect();
|
||||
let list = List::new(items)
|
||||
.block(
|
||||
Block::default().borders(Borders::ALL).title(format!(
|
||||
" {} (Enter: play, J/K: move, d: delete, Esc: back) ",
|
||||
pl.name
|
||||
)),
|
||||
)
|
||||
.highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD))
|
||||
.highlight_symbol("> ");
|
||||
let mut state = ratatui::widgets::ListState::default();
|
||||
state.select(Some(app.playlist_track_selected));
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
} else {
|
||||
let items: Vec<ListItem> = app
|
||||
.playlist_names
|
||||
.iter()
|
||||
.map(|n| ListItem::new(n.clone()))
|
||||
.collect();
|
||||
let list = List::new(items)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Playlists (Enter: open, n: new) "),
|
||||
)
|
||||
.highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD))
|
||||
.highlight_symbol("> ");
|
||||
let mut state = ratatui::widgets::ListState::default();
|
||||
state.select(Some(app.playlists_selected));
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_queue(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let items: Vec<ListItem> = app
|
||||
.current_queue
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| {
|
||||
let name = item.title_override.clone().unwrap_or_else(|| {
|
||||
item.path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("?")
|
||||
.to_string()
|
||||
});
|
||||
let marker = if i == app.queue_selected { "▶ " } else { " " };
|
||||
ListItem::new(format!("{marker}{name}"))
|
||||
})
|
||||
.collect();
|
||||
let list = List::new(items).block(Block::default().borders(Borders::ALL).title(" Queue "));
|
||||
frame.render_widget(list, area);
|
||||
}
|
||||
|
||||
fn draw_new_releases(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(2), Constraint::Min(0)])
|
||||
.split(area);
|
||||
|
||||
let checked_line = match &app.new_releases_checked_at {
|
||||
Some(t) => format!("Last checked: {}", t.format("%Y-%m-%d %H:%M")),
|
||||
None => "Never checked yet".to_string(),
|
||||
};
|
||||
let progress_line = if let Some((idx, total, artist)) = &app.new_releases_progress {
|
||||
format!("Checking… {idx}/{total} ({artist})")
|
||||
} else {
|
||||
format!("{checked_line} Period: {} (p: cycle) f: refresh all a: check one artist", app.new_releases_period.label())
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(Span::styled(
|
||||
"New releases — for artists already in your library",
|
||||
Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan),
|
||||
)),
|
||||
Line::from(progress_line),
|
||||
]),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
let days = match app.new_releases_period {
|
||||
library::history::StatsPeriod::Day => 1,
|
||||
library::history::StatsPeriod::Week => 7,
|
||||
library::history::StatsPeriod::Month => 30,
|
||||
};
|
||||
let filtered = library::new_releases::filter_within_days(&app.new_releases, days);
|
||||
|
||||
let items: Vec<ListItem> = if filtered.is_empty() {
|
||||
vec![ListItem::new(
|
||||
"(nothing found for this period — press 'f' to check, or widen the period with 'p')",
|
||||
)]
|
||||
} else {
|
||||
filtered
|
||||
.iter()
|
||||
.map(|r| {
|
||||
ListItem::new(format!(
|
||||
"{} — {} [{}] ({})",
|
||||
r.artist,
|
||||
r.title,
|
||||
r.primary_type,
|
||||
r.release_date.format("%Y-%m-%d")
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let list = List::new(items).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!(" {} new releases ", filtered.len())),
|
||||
);
|
||||
frame.render_widget(list, chunks[1]);
|
||||
}
|
||||
|
||||
fn draw_stats(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(1), Constraint::Min(0)])
|
||||
.split(area);
|
||||
|
||||
let history_status = if app.settings.history_enabled {
|
||||
"ON"
|
||||
} else {
|
||||
"OFF"
|
||||
};
|
||||
let reset_hint = if app.reset_armed {
|
||||
" Press 'x' AGAIN to confirm erase!"
|
||||
} else {
|
||||
" x: reset history"
|
||||
};
|
||||
let header = format!(
|
||||
"History tracking: {history_status} (toggle: h) Period: {} (p: cycle){reset_hint}",
|
||||
app.stats_period.label()
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(header).style(if app.reset_armed {
|
||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
}),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
let entries = app.history_store.load_all().unwrap_or_default();
|
||||
let total = library::history::total_count(&entries);
|
||||
let buckets = library::history::aggregate(&entries, app.stats_period);
|
||||
let recent = library::history::entries_within_period(&entries, app.stats_period);
|
||||
|
||||
let body = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
|
||||
.split(chunks[1]);
|
||||
|
||||
let bars: Vec<Bar> = buckets
|
||||
.iter()
|
||||
.map(|b| {
|
||||
Bar::default()
|
||||
.value(b.count)
|
||||
.label(Line::from(b.label.clone()))
|
||||
.text_value(b.count.to_string())
|
||||
})
|
||||
.collect();
|
||||
|
||||
let chart = BarChart::default()
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!(" Plays by {} — total {} ", app.stats_period.label().to_lowercase(), total)),
|
||||
)
|
||||
.data(BarGroup::default().bars(&bars))
|
||||
.bar_width(if app.stats_period == library::history::StatsPeriod::Day { 3 } else { 4 })
|
||||
.bar_gap(1)
|
||||
.bar_style(Style::default().fg(Color::Green))
|
||||
.value_style(Style::default().fg(Color::Black).bg(Color::Green));
|
||||
|
||||
frame.render_widget(chart, body[0]);
|
||||
|
||||
let track_items: Vec<ListItem> = if recent.is_empty() {
|
||||
vec![ListItem::new("(nothing played in this period yet)")]
|
||||
} else {
|
||||
recent
|
||||
.iter()
|
||||
.take(200)
|
||||
.map(|e| {
|
||||
ListItem::new(format!(
|
||||
"{} {} — {}",
|
||||
e.played_at.format("%d.%m %H:%M"),
|
||||
e.artist,
|
||||
e.title
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let track_list = List::new(track_items).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" What played (newest first) "),
|
||||
);
|
||||
frame.render_widget(track_list, body[1]);
|
||||
}
|
||||
|
||||
fn draw_radio(frame: &mut Frame, app: &App, area: Rect) {
|
||||
if app.station_search_in_progress {
|
||||
let msg = Paragraph::new("Searching online (doesn't block the UI — feel free to keep browsing other tabs)…")
|
||||
.alignment(Alignment::Center)
|
||||
.block(Block::default().borders(Borders::ALL).title(" Radio — searching… "));
|
||||
frame.render_widget(msg, area);
|
||||
return;
|
||||
}
|
||||
if !app.online_results.is_empty() {
|
||||
let items: Vec<ListItem> = app
|
||||
.online_results
|
||||
.iter()
|
||||
.map(|s| ListItem::new(s.name.clone()))
|
||||
.collect();
|
||||
let list = List::new(items)
|
||||
.block(Block::default().borders(Borders::ALL).title(
|
||||
" Search results (Enter: play, a: add to my stations, Esc: back) ",
|
||||
))
|
||||
.highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD))
|
||||
.highlight_symbol("> ");
|
||||
let mut state = ratatui::widgets::ListState::default();
|
||||
state.select(Some(app.online_selected));
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
return;
|
||||
}
|
||||
|
||||
let items: Vec<ListItem> = app
|
||||
.stations
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let playing = app.current_station.as_deref() == Some(s.url.as_str());
|
||||
let marker = if playing { "▶ " } else { " " };
|
||||
ListItem::new(format!("{marker}{}", s.name))
|
||||
})
|
||||
.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) ",
|
||||
))
|
||||
.highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD))
|
||||
.highlight_symbol("> ");
|
||||
let mut state = ratatui::widgets::ListState::default();
|
||||
state.select(Some(app.radio_selected));
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
}
|
||||
|
||||
/// Компактный рендер спектра для маленькой панели (внутри блока Cover/Spectrum) —
|
||||
/// простые вертикальные бары символами блоков, без BarChart (там подписи не влезли бы
|
||||
/// в такую узкую область).
|
||||
fn draw_spectrum_bars(frame: &mut Frame, spectrum: &[f32], area: Rect) {
|
||||
if area.height == 0 || area.width == 0 || spectrum.is_empty() {
|
||||
return;
|
||||
}
|
||||
let n = spectrum.len();
|
||||
let col_width = (area.width / n as u16).max(1);
|
||||
let mut lines: Vec<Line> = Vec::with_capacity(area.height as usize);
|
||||
for row in 0..area.height {
|
||||
let level_at_row = 1.0 - (row as f32 + 1.0) / area.height as f32; // 0 внизу, 1 наверху
|
||||
let mut spans = Vec::with_capacity(n);
|
||||
for &v in spectrum {
|
||||
let filled = v >= level_at_row;
|
||||
let ch = if filled { "█" } else { " " };
|
||||
spans.push(Span::raw(ch.repeat(col_width as usize)));
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines).style(Style::default().fg(Color::Green)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_now_playing(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(12), // обложка + инфо
|
||||
Constraint::Length(1), // транспорт (speed/shuffle/repeat)
|
||||
Constraint::Min(0), // эквалайзер
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let top = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
|
||||
.split(chunks[0]);
|
||||
|
||||
let protocol_name = app
|
||||
.picker
|
||||
.as_ref()
|
||||
.map(|p| protocol_to_str(p.protocol_type))
|
||||
.unwrap_or("none");
|
||||
let cover_title = match app.right_panel_view {
|
||||
RightPanelView::Cover => format!(" Cover [{protocol_name}] (v/z: view, I: cycle protocol) "),
|
||||
RightPanelView::Spectrum => " Spectrum (v/z: view) ".to_string(),
|
||||
};
|
||||
let cover_block = Block::default().borders(Borders::ALL).title(cover_title);
|
||||
let cover_inner = cover_block.inner(top[0]);
|
||||
frame.render_widget(cover_block, top[0]);
|
||||
match app.right_panel_view {
|
||||
RightPanelView::Cover => {
|
||||
if let Some(protocol) = app.cover_protocol.as_mut() {
|
||||
let image = StatefulImage::new(None);
|
||||
frame.render_stateful_widget(image, cover_inner, protocol);
|
||||
} else {
|
||||
let placeholder = Paragraph::new("(no cover)").alignment(Alignment::Center);
|
||||
frame.render_widget(placeholder, cover_inner);
|
||||
}
|
||||
}
|
||||
RightPanelView::Spectrum => draw_spectrum_bars(frame, &app.spectrum, cover_inner),
|
||||
}
|
||||
|
||||
draw_track_info(frame, app, top[1]);
|
||||
draw_transport(frame, app, chunks[1]);
|
||||
draw_eq(frame, app, chunks[2]);
|
||||
}
|
||||
|
||||
fn draw_track_info(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let block = Block::default().borders(Borders::ALL).title(" Now Playing ");
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0)])
|
||||
.split(inner);
|
||||
|
||||
if let Some(np) = &app.now_playing {
|
||||
frame.render_widget(Paragraph::new(Line::from(Span::styled(
|
||||
np.title.clone(),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
))), chunks[0]);
|
||||
frame.render_widget(Paragraph::new(np.artist.clone()), chunks[1]);
|
||||
frame.render_widget(Paragraph::new(np.album.clone()), chunks[2]);
|
||||
|
||||
if let Some(song) = &np.radio_song {
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!("♪ {song}")).style(Style::default().fg(Color::Cyan)),
|
||||
chunks[3],
|
||||
);
|
||||
}
|
||||
|
||||
let total = np.duration.unwrap_or_default().as_secs_f64().max(1.0);
|
||||
let pos = np.position.as_secs_f64().min(total);
|
||||
let ratio = (pos / total).clamp(0.0, 1.0);
|
||||
let label = if np.duration.is_some() {
|
||||
format!("{} / {}", fmt_time(np.position), fmt_time(np.duration.unwrap_or_default()))
|
||||
} else {
|
||||
format!("{} / live", fmt_time(np.position))
|
||||
};
|
||||
let gauge = Gauge::default()
|
||||
.gauge_style(Style::default().fg(Color::Green))
|
||||
.ratio(if np.duration.is_some() { ratio } else { 0.0 })
|
||||
.label(label);
|
||||
frame.render_widget(gauge, chunks[4]);
|
||||
} else {
|
||||
frame.render_widget(Paragraph::new("Nothing playing"), chunks[0]);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_transport(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let repeat_label = match app.repeat {
|
||||
player::RepeatMode::Off => "Off",
|
||||
player::RepeatMode::Track => "Track",
|
||||
player::RepeatMode::All => "All",
|
||||
};
|
||||
let line = format!(
|
||||
"v/z:view {:.2}x Shuf:{} Rep:{} Mute:{} Fade:{} Norm:{} ←→:seek",
|
||||
app.speed.0,
|
||||
if app.shuffle { "On" } else { "Off" },
|
||||
repeat_label,
|
||||
if app.muted { "On" } else { "Off" },
|
||||
if app.fade_out_enabled { "On" } else { "Off" },
|
||||
if app.loudness_norm_enabled { "On" } else { "Off" }
|
||||
);
|
||||
frame.render_widget(Paragraph::new(line), area);
|
||||
}
|
||||
|
||||
fn draw_eq(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Equalizer (e: focus, ←/→ band, ↑/↓ gain, 0: reset band) ");
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let n = player::EQ_BANDS;
|
||||
let constraints: Vec<Constraint> = (0..n).map(|_| Constraint::Ratio(1, n as u32)).collect();
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(constraints)
|
||||
.split(inner);
|
||||
|
||||
for band in 0..n {
|
||||
let gain = app.eq_gains[band];
|
||||
let selected = app.focus == Focus::Eq && app.eq_selected == band;
|
||||
draw_eq_band(frame, cols[band], band, gain, selected);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_eq_band(frame: &mut Frame, area: Rect, band: usize, gain: f32, selected: bool) {
|
||||
// Вертикальная полоса: делим высоту на верх (положительный gain) и низ (отрицательный),
|
||||
// с нулевой линией посередине.
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(1), Constraint::Length(1), Constraint::Length(1)])
|
||||
.split(area);
|
||||
|
||||
let bar_area = rows[0];
|
||||
let half = bar_area.height as f32 / 2.0;
|
||||
let filled = ((gain.abs() / 12.0) * half).round() as u16;
|
||||
let style = if selected {
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||
} else if gain > 0.0 {
|
||||
Style::default().fg(Color::Green)
|
||||
} else if gain < 0.0 {
|
||||
Style::default().fg(Color::Red)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let total_rows = bar_area.height;
|
||||
let mid = total_rows / 2;
|
||||
for row in 0..total_rows {
|
||||
let is_mid = row == mid;
|
||||
let filled_here = if gain >= 0.0 {
|
||||
row >= mid.saturating_sub(filled) && row < mid
|
||||
} else {
|
||||
row > mid && row <= mid + filled
|
||||
};
|
||||
let ch = if is_mid {
|
||||
"─"
|
||||
} else if filled_here {
|
||||
"█"
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
lines.push(Line::from(Span::styled(ch, style)));
|
||||
}
|
||||
frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), bar_area);
|
||||
|
||||
let freq_label = App::eq_band_freq_label(band);
|
||||
frame.render_widget(
|
||||
Paragraph::new(freq_label).alignment(Alignment::Center),
|
||||
rows[1],
|
||||
);
|
||||
let gain_label = format!("{:+.0}", gain);
|
||||
frame.render_widget(
|
||||
Paragraph::new(gain_label)
|
||||
.alignment(Alignment::Center)
|
||||
.style(style),
|
||||
rows[2],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let text = match app.focus {
|
||||
Focus::Search => format!("SEARCH: {} (Enter: confirm, Esc: cancel)", app.search_query),
|
||||
Focus::NewPlaylist => format!(
|
||||
"NEW PLAYLIST: {} (Enter: create, Esc: cancel)",
|
||||
app.new_playlist_name
|
||||
),
|
||||
Focus::NewStationName => format!(
|
||||
"NEW STATION NAME: {} (Enter: next, Esc: cancel)",
|
||||
app.new_station_name
|
||||
),
|
||||
Focus::NewStationUrl => format!(
|
||||
"NEW STATION URL: {} (Enter: save, Esc: cancel)",
|
||||
app.new_station_url
|
||||
),
|
||||
Focus::StationSearch => format!(
|
||||
"SEARCH STATIONS ONLINE: {} (Enter: search, Esc: cancel)",
|
||||
app.station_query
|
||||
),
|
||||
Focus::SmbServer => format!(
|
||||
"SMB — SERVER: {} (Enter: next, Esc: cancel)",
|
||||
app.smb_server
|
||||
),
|
||||
Focus::SmbShare => format!(
|
||||
"SMB — SHARE NAME: {} (Enter: next, Esc: cancel)",
|
||||
app.smb_share
|
||||
),
|
||||
Focus::SmbUsername => format!(
|
||||
"SMB — USERNAME (empty = anonymous/guest): {} (Enter: next)",
|
||||
app.smb_username
|
||||
),
|
||||
Focus::SmbPassword => format!(
|
||||
"SMB — PASSWORD (optional): {} (Enter: connect, Esc: cancel)",
|
||||
"*".repeat(app.smb_password.chars().count())
|
||||
),
|
||||
Focus::LibraryPathInput => format!(
|
||||
"ADD FOLDER: {} (Enter: scan, Esc: cancel)",
|
||||
app.library_path_input
|
||||
),
|
||||
Focus::NewReleaseArtistInput => format!(
|
||||
"CHECK ARTIST: {} (Enter: check, Esc: cancel)",
|
||||
app.new_release_artist_input
|
||||
),
|
||||
_ => app.status_message.clone(),
|
||||
};
|
||||
frame.render_widget(Paragraph::new(text), area);
|
||||
}
|
||||
|
||||
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