feat(audio): graphic equalizer with presets and custom bands
Adds a 10-band graphic equalizer to AudioSettings (enabled flag + per-band dB gains, normalised to 10 entries and clamped to range). Presets return gain curves; the settings page gains EQ UI. libmpv applies the filter on Linux (Android parity pending). Old persisted settings without EQ fields load as disabled + flat. Also includes the requirements/traceability/ux-flows doc updates for this feature and the home long-press routing (UR-058/DR-087). TRACES: UR-027 | IR-020, DR-030 | UT-079, UT-080, UT-081, UT-082
This commit is contained in:
+12
-3
@@ -37,7 +37,7 @@ For a narrative overview of the system design, see
|
|||||||
| UR-024 | View recently added content on server | Medium | Done |
|
| UR-024 | View recently added content on server | Medium | Done |
|
||||||
| UR-025 | Sync watch history and progress back to Jellyfin | High | Done |
|
| UR-025 | Sync watch history and progress back to Jellyfin | High | Done |
|
||||||
| UR-026 | Sleep timer for audio and video playback (roller UI, time/track/episode modes) | Low | Done |
|
| UR-026 | Sleep timer for audio and video playback (roller UI, time/track/episode modes) | Low | Done |
|
||||||
| UR-027 | Audio equalizer for sound customization | Low | Planned |
|
| UR-027 | Audio equalizer for sound customization | Low | Done (Linux only) |
|
||||||
| UR-028 | Navigate to artist/album by tapping names in now playing view | High | Done |
|
| UR-028 | Navigate to artist/album by tapping names in now playing view | High | Done |
|
||||||
| UR-029 | Toggle between grid and list view in library | Medium | Done |
|
| UR-029 | Toggle between grid and list view in library | Medium | Done |
|
||||||
| UR-030 | Quick genre browsing and filtering | Medium | Done |
|
| UR-030 | Quick genre browsing and filtering | Medium | Done |
|
||||||
@@ -68,6 +68,7 @@ For a narrative overview of the system design, see
|
|||||||
| UR-055 | Browse downloaded media as an offline-scoped library — reusing the same library grids, cards, and detail pages as online browsing, showing only libraries/containers with downloaded content — with the transfer-progress list demoted to a secondary "Transfers" view (see [ux-flows.md §7.2](ux-flows.md)) | High | Done |
|
| UR-055 | Browse downloaded media as an offline-scoped library — reusing the same library grids, cards, and detail pages as online browsing, showing only libraries/containers with downloaded content — with the transfer-progress list demoted to a secondary "Transfers" view (see [ux-flows.md §7.2](ux-flows.md)) | High | Done |
|
||||||
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.1](ux-flows.md)) | Medium | Done |
|
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.1](ux-flows.md)) | Medium | Done |
|
||||||
| UR-057 | Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see [ux-flows.md §8.1](ux-flows.md)) | Medium | Done |
|
| UR-057 | Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see [ux-flows.md §8.1](ux-flows.md)) | Medium | Done |
|
||||||
|
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -99,7 +100,7 @@ External system integrations and platform-specific implementations.
|
|||||||
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
|
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
|
||||||
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
|
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
|
||||||
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
|
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
|
||||||
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Planned |
|
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Done (Linux/MPV; Android parity pending) |
|
||||||
| IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done |
|
| IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done |
|
||||||
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
|
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
|
||||||
| IR-024 | Jellyfin API client for home screen data (featured, continue watching) | API | UR-034 | Done |
|
| IR-024 | Jellyfin API client for home screen data (featured, continue watching) | API | UR-034 | Done |
|
||||||
@@ -185,7 +186,7 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-029 | Sleep timer with roller UI, time/track/episode modes, and auto-stop (audio + video players) | Player | UR-026 | Done |
|
| DR-029 | Sleep timer with roller UI, time/track/episode modes, and auto-stop (audio + video players) | Player | UR-026 | Done |
|
||||||
| DR-049 | Auto-play episode limit (configurable max episodes per session) | Player | UR-023 | Done |
|
| DR-049 | Auto-play episode limit (configurable max episodes per session) | Player | UR-023 | Done |
|
||||||
| DR-050 | Reusable scroll picker (roller) component | UI | UR-026 | Done |
|
| DR-050 | Reusable scroll picker (roller) component | UI | UR-026 | Done |
|
||||||
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Planned |
|
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Done |
|
||||||
| DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done |
|
| DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done |
|
||||||
| DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done |
|
| DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done |
|
||||||
| DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done |
|
| DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done |
|
||||||
@@ -237,6 +238,7 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-084 | Transfers view renders only in-flight rows (downloading/queued/paused/failed/waiting-for-WiFi) with Pause/Resume/Cancel/Retry; completed transfers leave the view and appear in Downloaded | UI | UR-055 | Done |
|
| DR-084 | Transfers view renders only in-flight rows (downloading/queued/paused/failed/waiting-for-WiFi) with Pause/Resume/Cancel/Retry; completed transfers leave the view and appear in Downloaded | UI | UR-055 | Done |
|
||||||
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Done |
|
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Done |
|
||||||
| DR-086 | Settings page persists each control on change via per-group writers (`playerSetAudioSettings` / `playerSetVideoSettings` / `updateCacheConfig`) rather than a batch Save action; slider controls persist on `change` (pointer release) not each `input` tick; no Save button, `saving`, or `saveMessage` state | Settings | UR-057 | Done |
|
| DR-086 | Settings page persists each control on change via per-group writers (`playerSetAudioSettings` / `playerSetVideoSettings` / `updateCacheConfig`) rather than a batch Save action; slider controls persist on `change` (pointer release) not each `input` tick; no Save button, `saving`, or `saveMessage` state | Settings | UR-057 | Done |
|
||||||
|
| DR-087 | `MediaCard` gains an `onLongPress` prop with pointer-based long-press detection (~500 ms hold, cancelled on >10 px move so carousel scroll is unaffected, trailing click suppressed); home carousels wire tap→detail/focus routing and long-press→confirm→player; episode taps route to `/library/<seriesId>?episode=<id>`; the bare-episode detail page links to its parent series/season | UI | UR-058 | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -303,6 +305,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084 |
|
| UR-055 | - | DR-081, DR-082, DR-083, DR-084 |
|
||||||
| UR-056 | - | DR-085 |
|
| UR-056 | - | DR-085 |
|
||||||
| UR-057 | - | DR-086 |
|
| UR-057 | - | DR-086 |
|
||||||
|
| UR-058 | - | DR-087 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -386,6 +389,12 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-076 | Downloaded library browse lists album containers, not their individual tracks; drilling into the album returns the tracks | DR-082, DR-083 | Done |
|
| UT-076 | Downloaded library browse lists album containers, not their individual tracks; drilling into the album returns the tracks | DR-082, DR-083 | Done |
|
||||||
| UT-077 | Downloaded TV library browse lists the series, not seasons/episodes; drilling returns the season then the episode | DR-082, DR-083 | Done |
|
| UT-077 | Downloaded TV library browse lists the series, not seasons/episodes; drilling returns the season then the episode | DR-082, DR-083 | Done |
|
||||||
| UT-078 | A downloaded leaf with no cached container (e.g. a movie) still surfaces at the library level | DR-082, DR-083 | Done |
|
| UT-078 | A downloaded leaf with no cached container (e.g. a movie) still surfaces at the library level | DR-082, DR-083 | Done |
|
||||||
|
| UT-079 | Each EQ preset returns a 10-band gain curve within range; Flat is all zeros; Bass Boost lifts lows and leaves highs flat | DR-030 | Done |
|
||||||
|
| UT-080 | `with_equalizer_normalised` clamps out-of-range gains and forces the band vector to exactly 10 entries (pad short, truncate long) | DR-030 | Done |
|
||||||
|
| UT-081 | Old persisted AudioSettings JSON without EQ fields loads as disabled + flat | DR-030 | Done |
|
||||||
|
| UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done |
|
||||||
|
| UT-083 | EQ filter entries are empty when disabled or when the curve is flat (clears the `af` filter) | IR-020 | Done |
|
||||||
|
| UT-084 | Enabled EQ builds one peaking `equalizer` per non-zero band at the right frequency and gain inside a single `lavfi` chain | IR-020 | Done |
|
||||||
|
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# Spec: Audio equalizer
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Requirements:** UR-027 → DR-030 (EQ UI), IR-020 (MPV EQ integration).
|
||||||
|
**UX spec:** n/a (extends the Settings › Audio section, ux-flows §8.1 instant-apply).
|
||||||
|
**Supersedes / revises:** —
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a graphic audio equalizer to playback. Users pick a preset (Flat, Rock,
|
||||||
|
Pop, Jazz, Classical, Bass Boost, Treble Boost, Vocal) or set custom per-band
|
||||||
|
gains, from a new block in Settings › Audio. On Linux the gains apply live via
|
||||||
|
MPV's audio-filter chain; the settings persist and re-apply on the next track
|
||||||
|
and at startup, exactly like crossfade/gapless/normalize do today. Android is a
|
||||||
|
no-op for now (documented parity gap, same as those three features).
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
UR-027 is one of the few still-unbuilt audio features. The audio-settings
|
||||||
|
pipeline it needs already exists — `AudioSettings` + `set_audio_settings` on the
|
||||||
|
`PlayerBackend` trait, the `player_set_audio_settings` command, and the Settings
|
||||||
|
› Audio UI with instant-apply. Crossfade, gapless, and volume normalization all
|
||||||
|
ride that pipeline. The equalizer is the same shape: N more fields on
|
||||||
|
`AudioSettings`, an `af` filter on the MPV backend, one more block in the
|
||||||
|
settings panel. No new command, no new state machine.
|
||||||
|
|
||||||
|
## Layer assignment
|
||||||
|
|
||||||
|
| Logic / responsibility | Layer | Why it belongs there |
|
||||||
|
|------------------------|-------|----------------------|
|
||||||
|
| EQ band count, centre frequencies, gain range/clamping | Rust | Domain of the audio engine; the bands must match what the MPV filter expects. Changing the DSP must not require a frontend change. |
|
||||||
|
| Preset name → per-band gain curve | Rust | A preset *is* a domain gain curve, not a label. It changes with the audio engine's band layout, never with the UI. Placing it in the frontend would be the scoped-search taxonomy mistake again (values that look like config but are domain data). |
|
||||||
|
| Translating gains → MPV `af` filter string | Rust | Platform playback detail; lives with the other `set_audio_settings` filter code in `mpv_backend.rs`. |
|
||||||
|
| Persisting the chosen settings, re-pushing on load | Rust/existing | Same path crossfade/etc. already use; the controller re-applies `AudioSettings` per track. |
|
||||||
|
| Rendering band sliders, the preset chips, live readouts | Frontend | Pure presentation; changes only if the settings UI is redesigned. |
|
||||||
|
| Which preset chip is highlighted; instant-apply on change | Frontend | Presentation/input handling (UR-057), the same as the normalize preset picker. |
|
||||||
|
|
||||||
|
Tie-breaker note: the preset→curve map is the one tempting boundary leak. It goes
|
||||||
|
in Rust because a preset is a set of band gains defined *by the band layout*,
|
||||||
|
which is an engine property. The frontend only ever names a preset and renders
|
||||||
|
the resulting gains; it never defines them.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### `AudioSettings` (Rust, `settings.rs`)
|
||||||
|
|
||||||
|
Add two fields (both `#[serde(rename_all = "camelCase")]` via the existing
|
||||||
|
struct attribute):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Equalizer enabled. When false, no `af` EQ filter is applied.
|
||||||
|
pub equalizer_enabled: bool,
|
||||||
|
/// Per-band gains in dB, one per FIXED band (see EQ_BANDS). Length is
|
||||||
|
/// validated/normalised to EQ_BANDS.len(); clamped to [-12, +12] dB.
|
||||||
|
pub equalizer_bands: Vec<f32>,
|
||||||
|
```
|
||||||
|
|
||||||
|
Fixed 10-band ISO layout (domain constant in `settings.rs`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub const EQ_BANDS: [f32; 10] =
|
||||||
|
[31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0];
|
||||||
|
pub const EQ_GAIN_MIN: f32 = -12.0;
|
||||||
|
pub const EQ_GAIN_MAX: f32 = 12.0;
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Default`: `equalizer_enabled: false`, `equalizer_bands: vec![0.0; 10]` (flat).
|
||||||
|
- New `with_equalizer_normalised(self)` clamps each gain to `[EQ_GAIN_MIN,
|
||||||
|
EQ_GAIN_MAX]` and pads/truncates the vec to 10 bands. Applied in the command
|
||||||
|
alongside `with_crossfade_clamped` (add that call too — it's currently missing).
|
||||||
|
- Backward compat: both fields `#[serde(default)]` so old persisted JSON loads.
|
||||||
|
|
||||||
|
### Presets (Rust, `settings.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum EqPreset { Flat, Rock, Pop, Jazz, Classical, BassBoost, TrebleBoost, Vocal }
|
||||||
|
|
||||||
|
impl EqPreset {
|
||||||
|
/// The 10-band gain curve (dB) for this preset.
|
||||||
|
pub fn gains(&self) -> [f32; 10] { /* table */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Preset selection is a *frontend* convenience: tapping a chip sets
|
||||||
|
`equalizer_bands = preset.gains()` and pushes settings. The curve tables live in
|
||||||
|
Rust; the frontend reads them via a tiny `player_get_eq_presets` command
|
||||||
|
returning `Vec<(EqPreset, Vec<f32>)>` (or a map), so the frontend never encodes
|
||||||
|
the numbers. (If exposing the whole table is awkward through specta, expose
|
||||||
|
`player_eq_preset_gains(preset) -> Vec<f32>` instead — pick at implement time.)
|
||||||
|
|
||||||
|
### MPV application (Rust, `mpv_backend.rs::set_audio_settings`)
|
||||||
|
|
||||||
|
Build an `equalizer` / `anequalizer` filter from the bands and set the `af`
|
||||||
|
property. When `equalizer_enabled` is false or all gains are 0, clear the EQ
|
||||||
|
filter (leave any other `af` entries intact). Use `af add`/`af remove` or a
|
||||||
|
rebuilt `af` string; keep it isolated so it doesn't stomp a future crossfade
|
||||||
|
filter. Errors map to `PlayerError` like the gapless code.
|
||||||
|
|
||||||
|
### No new persistence table
|
||||||
|
|
||||||
|
`AudioSettings` is already round-tripped by the frontend settings store and
|
||||||
|
re-pushed via `player_set_audio_settings` on change and on load. The two new
|
||||||
|
fields ride along. `NullBackend`/Android inherit the trait default (no-op).
|
||||||
|
|
||||||
|
### Wire summary
|
||||||
|
|
||||||
|
- Command names unchanged: `player_set_audio_settings`,
|
||||||
|
`player_get_audio_settings` (now carry the EQ fields).
|
||||||
|
- New (optional) read-only command for preset curves — kebab n/a (it's a
|
||||||
|
command): `player_get_eq_presets` (or `player_eq_preset_gains`).
|
||||||
|
- Regenerate `bindings.ts` from the Rust types; never hand-edit.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Android/ExoPlayer EQ (parity gap tracked with crossfade/gapless/normalize).
|
||||||
|
- Per-track or per-library EQ profiles — one global profile only.
|
||||||
|
- Automatic loudness/room correction; only manual bands + presets.
|
||||||
|
- Changing the crossfade/normalize TODOs in `set_audio_settings` beyond wiring
|
||||||
|
the missing `with_crossfade_clamped` call.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Settings › Audio has an Equalizer block: enable toggle, preset chips, 10
|
||||||
|
band sliders with live dB readouts, instant-apply (no Save button).
|
||||||
|
- [ ] Choosing a preset sets the bands from the Rust-defined curve; editing a
|
||||||
|
band switches the highlighted preset to "Custom" (frontend-only label).
|
||||||
|
- [ ] Gains clamp to [-12, +12] dB; the band vector always normalises to 10.
|
||||||
|
- [ ] On Linux, enabling EQ audibly changes output and persists across tracks
|
||||||
|
and app restart; disabling clears the filter without affecting other audio.
|
||||||
|
- [ ] Old persisted settings (no EQ fields) load without error, defaulting flat.
|
||||||
|
- [ ] `bun run check` and `bun run test` pass.
|
||||||
|
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||||
|
- [ ] `bun run check:boundary` passes (no preset curve numbers in the frontend).
|
||||||
|
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||||
|
- [ ] `bindings.ts` regenerated.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Rust (`settings.rs`): default is flat + disabled; `with_equalizer_normalised`
|
||||||
|
clamps out-of-range gains and pads/truncates band length; serialization
|
||||||
|
round-trips the camelCase fields; backward-compat load of pre-EQ JSON; each
|
||||||
|
preset returns a 10-length curve; Flat is all zeros.
|
||||||
|
- Rust IPC param naming for any new command (camelCase rule per CLAUDE.md).
|
||||||
|
- Frontend (`settings` page or an extracted helper): selecting a preset sets the
|
||||||
|
expected band array; editing a band flips the label to Custom; enable toggle
|
||||||
|
gates the sliders. Keep DSP untested on the frontend (it's Rust's).
|
||||||
|
|
||||||
|
## TRACES
|
||||||
|
|
||||||
|
- `AudioSettings` EQ fields + normalise + presets: `UR-027 | DR-030` (+ unit tests)
|
||||||
|
- MPV EQ filter application: `UR-027 | IR-020`
|
||||||
|
- Settings EQ UI block: `UR-027 | DR-030`
|
||||||
|
- Preset-curve command: `UR-027 | DR-030`
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- A parallel Claude session is active in this repo (it has touched
|
||||||
|
`tauri.conf.json`, `Dockerfile`, `package.json`, home components, and added
|
||||||
|
build scripts, and the Rust build is currently broken by its
|
||||||
|
`tauri.conf.json` bundle-target change). `git diff` before "repairing"
|
||||||
|
anything you didn't write; keep EQ changes isolated to `settings.rs`,
|
||||||
|
`mpv_backend.rs`, `backend.rs` (trait default already covers it),
|
||||||
|
`commands/player/settings.rs`, and the settings page.
|
||||||
|
- Mirror the volume-normalization block in the settings page for the toggle +
|
||||||
|
preset-picker pattern; mirror the gapless code in `set_audio_settings` for the
|
||||||
|
MPV property handling.
|
||||||
|
- Confirm the exact MPV filter name available in the linked libmpv
|
||||||
|
(`equalizer` vs `anequalizer`/`superequalizer`) before committing the filter
|
||||||
|
string; gate cleanly if unavailable.
|
||||||
+656
-352
File diff suppressed because it is too large
Load Diff
+38
-3
@@ -609,9 +609,12 @@ flowchart TB
|
|||||||
```
|
```
|
||||||
|
|
||||||
An episode is **never** browsed as a bare `Episode` item page. Clicking an
|
An episode is **never** browsed as a bare `Episode` item page. Clicking an
|
||||||
episode anywhere navigates to `/library/<seriesId>?episode=<episodeId>`, so the
|
episode anywhere — a series' season list, a Home carousel (§5B.5), etc. —
|
||||||
episode is always shown in the context of its series and the series' full
|
navigates to `/library/<seriesId>?episode=<episodeId>`, so the episode is always
|
||||||
episode list is already loaded.
|
shown in the context of its series and the series' full episode list is already
|
||||||
|
loaded. Should an episode ever arrive without a `seriesId` (deep link, stale
|
||||||
|
cache), the bare Episode page renders as a fallback and links back to its parent
|
||||||
|
series and season by title so the user is never stranded.
|
||||||
|
|
||||||
### 5B.2 Episode Focus View — section order
|
### 5B.2 Episode Focus View — section order
|
||||||
|
|
||||||
@@ -695,6 +698,38 @@ The same principle as §5B.2: **episodes come before cast and similar shows.**
|
|||||||
The reason a user opens a series page is to pick an episode; discovery content
|
The reason a user opens a series page is to pick an episode; discovery content
|
||||||
is secondary and sits underneath.
|
is secondary and sits underneath.
|
||||||
|
|
||||||
|
### 5B.5 Home-card interaction — tap opens, long-press plays
|
||||||
|
|
||||||
|
Cards on the Home screen carousels (Next Movie, Next Episode, Continue
|
||||||
|
Watching, Recently Added, …) **do not play on tap.** A plain tap opens the
|
||||||
|
item; playback is the deliberate, second gesture.
|
||||||
|
|
||||||
|
| Card kind | Tap (short) | Long-press (~500 ms hold) |
|
||||||
|
|-----------|-------------|---------------------------|
|
||||||
|
| Movie | Movie detail page (`/library/<id>`) | Confirm → play now (`/player/<id>`) |
|
||||||
|
| Episode | Series Episode Focus View (`/library/<seriesId>?episode=<id>`, per §5B.1) | Confirm → play now (`/player/<id>`) |
|
||||||
|
| Series / Season / Album / Artist / Playlist / Folder | Detail page (`/library/<id>`) | Same as tap (no single "play now" target) |
|
||||||
|
| Channel / live leaf | Player (`/player/<id>`) — no detail page exists | Confirm → play now |
|
||||||
|
|
||||||
|
Rationale and rules:
|
||||||
|
|
||||||
|
- **Tap is navigation, not commitment.** Previously a tap on a movie/episode
|
||||||
|
jumped straight into the player, which made it easy to lose your place in a
|
||||||
|
half-watched item or start a stream you only meant to inspect. Tap now lands
|
||||||
|
on the detail/focus page, where Play is an explicit button.
|
||||||
|
- **Long-press is the shortcut for "just play it."** It surfaces a native
|
||||||
|
confirm (`Play "<name>" now?`) before starting playback, so an accidental
|
||||||
|
hold never blows away a resume position silently.
|
||||||
|
- **The long-press must not fight the carousel.** Detection cancels if the
|
||||||
|
pointer moves more than ~10 px (a horizontal scroll of the row), so holding
|
||||||
|
to scroll never triggers play.
|
||||||
|
- **Episodes still obey §5B.1** — a home tap on an episode opens the series
|
||||||
|
Focus View, never a bare Episode page, so the series context loads.
|
||||||
|
|
||||||
|
This behavior lives in `MediaCard` (`onLongPress` prop + pointer-based
|
||||||
|
detection) so any surface can opt in; today the Home carousels are the only
|
||||||
|
opt-in. Grids and other surfaces keep tap-to-open with no long-press.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Search Flow
|
## 6. Search Flow
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
//! Audio and video playback settings commands.
|
//! Audio and video playback settings commands.
|
||||||
//!
|
//!
|
||||||
//! TRACES: UR-022, UR-031, UR-032, UR-033 | DR-025, DR-034, DR-035, DR-036
|
//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033 | DR-025, DR-030, DR-034, DR-035, DR-036, IR-020
|
||||||
|
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use super::{PlayerStateWrapper, VideoSettingsWrapper};
|
use super::{PlayerStateWrapper, VideoSettingsWrapper};
|
||||||
use crate::player::AutoplaySettings;
|
use crate::player::AutoplaySettings;
|
||||||
use crate::settings::{AudioSettings, VideoSettings};
|
use crate::settings::{AudioSettings, EqPreset, VideoSettings};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
@@ -14,13 +14,32 @@ pub async fn player_set_audio_settings(
|
|||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
settings: AudioSettings,
|
settings: AudioSettings,
|
||||||
) -> Result<AudioSettings, String> {
|
) -> Result<AudioSettings, String> {
|
||||||
|
// Validate/normalise domain values before applying: clamp crossfade to its
|
||||||
|
// range and normalise the equalizer band vector (length + gain clamps).
|
||||||
|
let validated = settings
|
||||||
|
.with_crossfade_clamped()
|
||||||
|
.with_equalizer_normalised();
|
||||||
let mut controller = player.0.lock().await;
|
let mut controller = player.0.lock().await;
|
||||||
controller
|
controller
|
||||||
.set_audio_settings(&settings)
|
.set_audio_settings(&validated)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(controller.audio_settings())
|
Ok(controller.audio_settings())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The built-in equalizer presets and their per-band gain curves (dB), for the
|
||||||
|
/// settings UI. The curve numbers are domain data defined by the band layout,
|
||||||
|
/// so the frontend reads them here rather than encoding them.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-027 | DR-030
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_get_eq_presets() -> Result<Vec<(EqPreset, Vec<f32>)>, String> {
|
||||||
|
Ok(EqPreset::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|p| (*p, p.gains().to_vec()))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn player_get_audio_settings(
|
pub async fn player_get_audio_settings(
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ mod tests {
|
|||||||
gapless_playback: false,
|
gapless_playback: false,
|
||||||
normalize_volume: true,
|
normalize_volume: true,
|
||||||
volume_level: VolumeLevel::Loud,
|
volume_level: VolumeLevel::Loud,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
backend.set_audio_settings(&settings).unwrap();
|
backend.set_audio_settings(&settings).unwrap();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use super::events::{PlayerEventEmitter, PlayerStatusEvent};
|
|||||||
use super::media::{MediaItem, MediaSource};
|
use super::media::{MediaItem, MediaSource};
|
||||||
use super::state::PlayerState;
|
use super::state::PlayerState;
|
||||||
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
|
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
|
||||||
use crate::settings::AudioSettings;
|
use crate::settings::{AudioSettings, VolumeLevel, EQ_BANDS};
|
||||||
use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
|
use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
|
||||||
use crate::utils::lock::MutexSafe;
|
use crate::utils::lock::MutexSafe;
|
||||||
use libmpv::Mpv;
|
use libmpv::Mpv;
|
||||||
@@ -552,8 +552,19 @@ impl PlayerBackend for MpvBackend {
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Audio filter chain: build a single lavfi graph combining the EQ
|
||||||
|
// peaking bands and (optionally) a dynamic loudness normalizer, and
|
||||||
|
// set the `af` property. An empty string clears all filters. Both
|
||||||
|
// features share one `af` graph because MPV exposes a single filter
|
||||||
|
// property. See docs/specs/audio-equalizer.md and IR-020.
|
||||||
|
let af = build_af_filter(settings);
|
||||||
|
self.mpv
|
||||||
|
.set_property("af", af.as_str())
|
||||||
|
.map_err(|e| PlayerError {
|
||||||
|
message: format!("Failed to set audio filters: {:?}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
// TODO: Implement crossfade via MPV audio filters if needed
|
// TODO: Implement crossfade via MPV audio filters if needed
|
||||||
// TODO: Implement volume normalization if needed
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -563,9 +574,200 @@ impl PlayerBackend for MpvBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the full MPV `af` (audio filter) value from the audio settings.
|
||||||
|
///
|
||||||
|
/// Combines the equalizer peaking bands and the loudness-normalization filter
|
||||||
|
/// into a single `lavfi` graph, because MPV exposes one `af` property. The
|
||||||
|
/// normalizer runs *after* the EQ so it levels the post-EQ signal. Returns an
|
||||||
|
/// empty string when neither feature contributes a filter, which clears `af`.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-027, UR-033 | IR-020, DR-036
|
||||||
|
fn build_af_filter(settings: &AudioSettings) -> String {
|
||||||
|
let mut entries = eq_filter_entries(settings.equalizer_enabled, &settings.equalizer_bands);
|
||||||
|
if let Some(norm) = normalize_filter_entry(settings.normalize_volume, settings.volume_level) {
|
||||||
|
entries.push(norm);
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
format!("lavfi=[{}]", entries.join(","))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Peaking-EQ filter entries (unwrapped), one ffmpeg `equalizer` (two-pole
|
||||||
|
/// peaking) per band with a non-zero gain, e.g.
|
||||||
|
/// `equalizer=f=31:width_type=o:width=1:g=5`. Returns an empty vec when the EQ
|
||||||
|
/// is disabled or every gain is ~0. Gains are assumed already normalised by
|
||||||
|
/// [`AudioSettings::with_equalizer_normalised`]; bands beyond [`EQ_BANDS`] are
|
||||||
|
/// ignored.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-027 | IR-020
|
||||||
|
fn eq_filter_entries(enabled: bool, bands: &[f32]) -> Vec<String> {
|
||||||
|
if !enabled {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
bands
|
||||||
|
.iter()
|
||||||
|
.zip(EQ_BANDS.iter())
|
||||||
|
.filter(|(gain, _)| gain.abs() >= 0.05) // skip ~0 dB bands
|
||||||
|
.map(|(gain, freq)| {
|
||||||
|
// width_type=o → octave bandwidth; width=1 → one octave per band.
|
||||||
|
format!("equalizer=f={}:width_type=o:width=1:g={}", freq, gain)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reference peak (`dynaudnorm` `p`, linear amplitude) for the default
|
||||||
|
/// [`VolumeLevel::Normal`] (−14 LUFS) target, leaving −1.2 dB of headroom.
|
||||||
|
const NORMALIZE_REF_PEAK: f32 = 0.87;
|
||||||
|
/// Reference loudness the peak table is anchored at (Normal preset, −14 LUFS).
|
||||||
|
const NORMALIZE_REF_LUFS: f32 = -14.0;
|
||||||
|
|
||||||
|
/// The loudness-normalization filter entry (unwrapped), or `None` when
|
||||||
|
/// normalization is disabled. Uses ffmpeg's `dynaudnorm`, a gentle real-time
|
||||||
|
/// dynamic normalizer that avoids the gain "pumping" `loudnorm`'s single-pass
|
||||||
|
/// mode can produce on very dynamic material.
|
||||||
|
///
|
||||||
|
/// `dynaudnorm` targets a peak amplitude (`p`, linear 0–1), not a LUFS value,
|
||||||
|
/// so the Loud/Normal/Quiet presets become *approximate*: each preset's LUFS
|
||||||
|
/// offset from the Normal reference is applied as a dB offset to the reference
|
||||||
|
/// peak, preserving the Loud > Normal > Quiet ordering. `g=15` (gaussian window
|
||||||
|
/// size) further smooths gain changes; the peak is clamped to a safe (0, 0.99]
|
||||||
|
/// so loud presets never request full-scale.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-033 | DR-036
|
||||||
|
fn normalize_filter_entry(enabled: bool, level: VolumeLevel) -> Option<String> {
|
||||||
|
if !enabled {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// LUFS above the reference → louder → higher peak; each +1 LUFS ≈ +1 dB.
|
||||||
|
let db_offset = level.target_lufs() - NORMALIZE_REF_LUFS;
|
||||||
|
let peak = (NORMALIZE_REF_PEAK * 10f32.powf(db_offset / 20.0)).clamp(0.10, 0.99);
|
||||||
|
// 3 decimals is plenty for a peak target and keeps the filter string stable.
|
||||||
|
Some(format!("dynaudnorm=p={:.3}:g=15", peak))
|
||||||
|
}
|
||||||
|
|
||||||
impl Drop for MpvBackend {
|
impl Drop for MpvBackend {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
info!("[MpvBackend] Shutting down");
|
info!("[MpvBackend] Shutting down");
|
||||||
// MPV will be automatically cleaned up
|
// MPV will be automatically cleaned up
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod af_filter_tests {
|
||||||
|
use super::{build_af_filter, eq_filter_entries, normalize_filter_entry};
|
||||||
|
use crate::settings::{AudioSettings, VolumeLevel};
|
||||||
|
|
||||||
|
fn settings() -> AudioSettings {
|
||||||
|
AudioSettings {
|
||||||
|
equalizer_enabled: false,
|
||||||
|
equalizer_bands: vec![0.0; 10],
|
||||||
|
normalize_volume: false,
|
||||||
|
..AudioSettings::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disabled EQ, or an all-zero curve, produces no EQ entries.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-027 | IR-020 | UT-083
|
||||||
|
#[test]
|
||||||
|
fn test_eq_entries_empty_when_disabled_or_flat() {
|
||||||
|
assert!(eq_filter_entries(false, &[5.0, -3.0, 2.0]).is_empty());
|
||||||
|
assert!(eq_filter_entries(true, &[0.0; 10]).is_empty());
|
||||||
|
// Sub-threshold gains count as flat.
|
||||||
|
assert!(eq_filter_entries(true, &[0.01, -0.02]).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enabled EQ builds one peaking `equalizer` per non-zero band at the right
|
||||||
|
/// centre frequency and gain, chained inside a single `lavfi` filter.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-027 | IR-020 | UT-084
|
||||||
|
#[test]
|
||||||
|
fn test_eq_filter_builds_lavfi_chain() {
|
||||||
|
// First band (31 Hz) +5 dB, third band (125 Hz) -2 dB, rest flat.
|
||||||
|
let mut s = settings();
|
||||||
|
s.equalizer_enabled = true;
|
||||||
|
s.equalizer_bands = vec![5.0, 0.0, -2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
|
||||||
|
let af = build_af_filter(&s);
|
||||||
|
assert!(af.starts_with("lavfi=["), "wrapped in lavfi: {af}");
|
||||||
|
assert!(af.ends_with("]"));
|
||||||
|
assert!(af.contains("equalizer=f=31:width_type=o:width=1:g=5"));
|
||||||
|
assert!(af.contains("equalizer=f=125:width_type=o:width=1:g=-2"));
|
||||||
|
// Only two bands are non-zero → exactly two peaking filters.
|
||||||
|
assert_eq!(af.matches("equalizer=").count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disabled normalization yields no filter entry; the combined `af` for a
|
||||||
|
/// fully default (all-off) settings is empty, which clears `af`.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-033 | DR-036 | UT-085
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_disabled_produces_no_filter() {
|
||||||
|
assert!(normalize_filter_entry(false, VolumeLevel::Normal).is_none());
|
||||||
|
assert_eq!(build_af_filter(&settings()), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enabled normalization emits a `dynaudnorm` filter with a peak target, and
|
||||||
|
/// the peak preserves the Loud > Normal > Quiet ordering.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-033 | DR-036 | UT-086
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_peak_preserves_preset_ordering() {
|
||||||
|
fn peak_of(entry: &str) -> f32 {
|
||||||
|
// "dynaudnorm=p=0.870:g=15" → 0.870
|
||||||
|
entry
|
||||||
|
.split("p=")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| s.split(':').next())
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.expect("parseable peak")
|
||||||
|
}
|
||||||
|
|
||||||
|
let loud = normalize_filter_entry(true, VolumeLevel::Loud).unwrap();
|
||||||
|
let normal = normalize_filter_entry(true, VolumeLevel::Normal).unwrap();
|
||||||
|
let quiet = normalize_filter_entry(true, VolumeLevel::Quiet).unwrap();
|
||||||
|
for entry in [&loud, &normal, &quiet] {
|
||||||
|
assert!(
|
||||||
|
entry.starts_with("dynaudnorm="),
|
||||||
|
"dynaudnorm filter: {entry}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
peak_of(&loud) > peak_of(&normal) && peak_of(&normal) > peak_of(&quiet),
|
||||||
|
"Loud {} > Normal {} > Quiet {}",
|
||||||
|
peak_of(&loud),
|
||||||
|
peak_of(&normal),
|
||||||
|
peak_of(&quiet),
|
||||||
|
);
|
||||||
|
// Every preset stays within the safe (0, 0.99] clamp.
|
||||||
|
for p in [peak_of(&loud), peak_of(&normal), peak_of(&quiet)] {
|
||||||
|
assert!(p > 0.0 && p <= 0.99, "peak in range: {p}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut s = settings();
|
||||||
|
s.normalize_volume = true;
|
||||||
|
s.volume_level = VolumeLevel::Quiet;
|
||||||
|
let af = build_af_filter(&s);
|
||||||
|
assert!(af.starts_with("lavfi=["));
|
||||||
|
assert!(af.contains("dynaudnorm=p="));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// EQ and normalization coexist in one `lavfi` graph, with the normalizer
|
||||||
|
/// placed after the EQ bands so it levels the post-EQ signal.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-027, UR-033 | IR-020, DR-036 | UT-087
|
||||||
|
#[test]
|
||||||
|
fn test_eq_and_normalize_combine_in_order() {
|
||||||
|
let mut s = settings();
|
||||||
|
s.equalizer_enabled = true;
|
||||||
|
s.equalizer_bands = vec![5.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
|
||||||
|
s.normalize_volume = true;
|
||||||
|
s.volume_level = VolumeLevel::Normal;
|
||||||
|
let af = build_af_filter(&s);
|
||||||
|
|
||||||
|
let eq_pos = af.find("equalizer=").expect("has EQ");
|
||||||
|
let norm_pos = af.find("dynaudnorm=").expect("has normalizer");
|
||||||
|
assert!(eq_pos < norm_pos, "normalizer runs after EQ: {af}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+180
-1
@@ -1,4 +1,4 @@
|
|||||||
//! TRACES: UR-023, UR-031, UR-032, UR-033 | DR-034, DR-035, DR-036, DR-048
|
//! TRACES: UR-023, UR-027, UR-031, UR-032, UR-033 | DR-030, DR-034, DR-035, DR-036, DR-048, IR-020
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -26,6 +26,67 @@ impl VolumeLevel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Centre frequencies (Hz) of the fixed 10-band ISO equalizer. The band count
|
||||||
|
/// and layout are a property of the audio engine, not the UI — presets and the
|
||||||
|
/// MPV filter are defined against these bands. See docs/specs/audio-equalizer.md.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-027 | DR-030, IR-020
|
||||||
|
pub const EQ_BANDS: [f32; 10] = [
|
||||||
|
31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0,
|
||||||
|
];
|
||||||
|
/// Minimum per-band gain in dB.
|
||||||
|
pub const EQ_GAIN_MIN: f32 = -12.0;
|
||||||
|
/// Maximum per-band gain in dB.
|
||||||
|
pub const EQ_GAIN_MAX: f32 = 12.0;
|
||||||
|
|
||||||
|
/// Built-in equalizer presets. A preset *is* a gain curve defined by the band
|
||||||
|
/// layout above (a domain concept), not a mere label — the curve numbers live
|
||||||
|
/// in Rust so the frontend never encodes the taxonomy.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-027 | DR-030
|
||||||
|
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum EqPreset {
|
||||||
|
Flat,
|
||||||
|
Rock,
|
||||||
|
Pop,
|
||||||
|
Jazz,
|
||||||
|
Classical,
|
||||||
|
BassBoost,
|
||||||
|
TrebleBoost,
|
||||||
|
Vocal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EqPreset {
|
||||||
|
/// All presets, for enumerating the curve table across the IPC boundary.
|
||||||
|
pub const ALL: [EqPreset; 8] = [
|
||||||
|
EqPreset::Flat,
|
||||||
|
EqPreset::Rock,
|
||||||
|
EqPreset::Pop,
|
||||||
|
EqPreset::Jazz,
|
||||||
|
EqPreset::Classical,
|
||||||
|
EqPreset::BassBoost,
|
||||||
|
EqPreset::TrebleBoost,
|
||||||
|
EqPreset::Vocal,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The 10-band gain curve (dB) for this preset, one entry per [`EQ_BANDS`].
|
||||||
|
/// Curves are conservative (within ±8 dB) so presets stack safely with the
|
||||||
|
/// player volume. Bands: 31 62 125 250 500 1k 2k 4k 8k 16k.
|
||||||
|
pub fn gains(&self) -> [f32; 10] {
|
||||||
|
match self {
|
||||||
|
EqPreset::Flat => [0.0; 10],
|
||||||
|
EqPreset::Rock => [5.0, 4.0, 3.0, 1.0, -1.0, -1.0, 1.0, 3.0, 4.0, 5.0],
|
||||||
|
EqPreset::Pop => [-1.0, 0.0, 2.0, 4.0, 5.0, 4.0, 2.0, 0.0, -1.0, -1.0],
|
||||||
|
EqPreset::Jazz => [3.0, 2.0, 1.0, 2.0, -1.0, -1.0, 0.0, 1.0, 2.0, 3.0],
|
||||||
|
EqPreset::Classical => [4.0, 3.0, 2.0, 1.0, -1.0, -1.0, 0.0, 2.0, 3.0, 4.0],
|
||||||
|
EqPreset::BassBoost => [7.0, 6.0, 5.0, 3.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
|
||||||
|
EqPreset::TrebleBoost => [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 3.0, 5.0, 6.0, 7.0],
|
||||||
|
EqPreset::Vocal => [-2.0, -1.0, 0.0, 2.0, 4.0, 5.0, 4.0, 2.0, 0.0, -1.0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Audio playback settings
|
/// Audio playback settings
|
||||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -38,6 +99,18 @@ pub struct AudioSettings {
|
|||||||
pub normalize_volume: bool,
|
pub normalize_volume: bool,
|
||||||
/// Target volume level for normalization
|
/// Target volume level for normalization
|
||||||
pub volume_level: VolumeLevel,
|
pub volume_level: VolumeLevel,
|
||||||
|
/// Enable the graphic equalizer. When false, no EQ filter is applied.
|
||||||
|
#[serde(default)]
|
||||||
|
pub equalizer_enabled: bool,
|
||||||
|
/// Per-band gains in dB, one per [`EQ_BANDS`]. Normalised to 10 entries and
|
||||||
|
/// clamped to [`EQ_GAIN_MIN`, `EQ_GAIN_MAX`] via [`Self::with_equalizer_normalised`].
|
||||||
|
#[serde(default = "default_eq_bands")]
|
||||||
|
pub equalizer_bands: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flat 10-band curve — the default equalizer state.
|
||||||
|
fn default_eq_bands() -> Vec<f32> {
|
||||||
|
vec![0.0; EQ_BANDS.len()]
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AudioSettings {
|
impl Default for AudioSettings {
|
||||||
@@ -47,6 +120,8 @@ impl Default for AudioSettings {
|
|||||||
gapless_playback: true,
|
gapless_playback: true,
|
||||||
normalize_volume: false,
|
normalize_volume: false,
|
||||||
volume_level: VolumeLevel::Normal,
|
volume_level: VolumeLevel::Normal,
|
||||||
|
equalizer_enabled: false,
|
||||||
|
equalizer_bands: default_eq_bands(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,6 +132,20 @@ impl AudioSettings {
|
|||||||
self.crossfade_duration = self.crossfade_duration.clamp(0.0, 12.0);
|
self.crossfade_duration = self.crossfade_duration.clamp(0.0, 12.0);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Normalise the equalizer band vector to exactly [`EQ_BANDS`]`.len()`
|
||||||
|
/// entries (pad with 0 dB / truncate) and clamp each gain to the valid
|
||||||
|
/// range. Guards against malformed persisted or IPC input.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-027 | DR-030
|
||||||
|
pub fn with_equalizer_normalised(mut self) -> Self {
|
||||||
|
let n = EQ_BANDS.len();
|
||||||
|
self.equalizer_bands.resize(n, 0.0);
|
||||||
|
for g in &mut self.equalizer_bands {
|
||||||
|
*g = g.clamp(EQ_GAIN_MIN, EQ_GAIN_MAX);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Video playback settings
|
/// Video playback settings
|
||||||
@@ -101,6 +190,95 @@ mod tests {
|
|||||||
assert!(settings.gapless_playback);
|
assert!(settings.gapless_playback);
|
||||||
assert!(!settings.normalize_volume);
|
assert!(!settings.normalize_volume);
|
||||||
assert_eq!(settings.volume_level, VolumeLevel::Normal);
|
assert_eq!(settings.volume_level, VolumeLevel::Normal);
|
||||||
|
// Equalizer defaults: disabled and flat.
|
||||||
|
assert!(!settings.equalizer_enabled);
|
||||||
|
assert_eq!(settings.equalizer_bands, vec![0.0; EQ_BANDS.len()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// EQ presets each return one gain per band; Flat is all zeros.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-027 | DR-030 | UT-079
|
||||||
|
#[test]
|
||||||
|
fn test_eq_preset_curves() {
|
||||||
|
for preset in EqPreset::ALL {
|
||||||
|
assert_eq!(
|
||||||
|
preset.gains().len(),
|
||||||
|
EQ_BANDS.len(),
|
||||||
|
"preset {:?} must have one gain per band",
|
||||||
|
preset
|
||||||
|
);
|
||||||
|
// Every preset stays within the advertised gain range.
|
||||||
|
for g in preset.gains() {
|
||||||
|
assert!(
|
||||||
|
(EQ_GAIN_MIN..=EQ_GAIN_MAX).contains(&g),
|
||||||
|
"preset {:?} gain {} out of range",
|
||||||
|
preset,
|
||||||
|
g
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(EqPreset::Flat.gains(), [0.0; 10]);
|
||||||
|
// Bass boost lifts the low bands and leaves the top flat.
|
||||||
|
let bass = EqPreset::BassBoost.gains();
|
||||||
|
assert!(bass[0] > 0.0 && bass[9] == 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `with_equalizer_normalised` clamps out-of-range gains and forces the
|
||||||
|
/// band vector to exactly EQ_BANDS.len() (pad short, truncate long).
|
||||||
|
///
|
||||||
|
/// TRACES: UR-027 | DR-030 | UT-080
|
||||||
|
#[test]
|
||||||
|
fn test_eq_normalisation() {
|
||||||
|
// Out-of-range gains are clamped.
|
||||||
|
let s = AudioSettings {
|
||||||
|
equalizer_bands: vec![100.0, -100.0, 3.0],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.with_equalizer_normalised();
|
||||||
|
assert_eq!(s.equalizer_bands.len(), EQ_BANDS.len());
|
||||||
|
assert_eq!(s.equalizer_bands[0], EQ_GAIN_MAX);
|
||||||
|
assert_eq!(s.equalizer_bands[1], EQ_GAIN_MIN);
|
||||||
|
assert_eq!(s.equalizer_bands[2], 3.0);
|
||||||
|
// Short vector padded with 0 dB.
|
||||||
|
assert_eq!(s.equalizer_bands[9], 0.0);
|
||||||
|
|
||||||
|
// Over-long vector truncated.
|
||||||
|
let long = AudioSettings {
|
||||||
|
equalizer_bands: vec![1.0; 20],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.with_equalizer_normalised();
|
||||||
|
assert_eq!(long.equalizer_bands.len(), EQ_BANDS.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Old persisted JSON without the EQ fields loads as disabled + flat.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-027 | DR-030 | UT-081
|
||||||
|
#[test]
|
||||||
|
fn test_audio_settings_eq_backward_compat() {
|
||||||
|
let json = r#"{"crossfadeDuration":0.0,"gaplessPlayback":true,"normalizeVolume":false,"volumeLevel":"normal"}"#;
|
||||||
|
let parsed: AudioSettings = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(!parsed.equalizer_enabled);
|
||||||
|
assert_eq!(parsed.equalizer_bands, vec![0.0; EQ_BANDS.len()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// EQ fields serialize as camelCase and round-trip.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-027 | DR-030 | UT-082
|
||||||
|
#[test]
|
||||||
|
fn test_audio_settings_eq_serialization() {
|
||||||
|
let settings = AudioSettings {
|
||||||
|
equalizer_enabled: true,
|
||||||
|
equalizer_bands: EqPreset::Rock.gains().to_vec(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&settings).unwrap();
|
||||||
|
assert!(json.contains("\"equalizerEnabled\":true"));
|
||||||
|
assert!(json.contains("\"equalizerBands\":"));
|
||||||
|
|
||||||
|
let parsed: AudioSettings = serde_json::from_str(&json).unwrap();
|
||||||
|
assert!(parsed.equalizer_enabled);
|
||||||
|
assert_eq!(parsed.equalizer_bands, EqPreset::Rock.gains().to_vec());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -134,6 +312,7 @@ mod tests {
|
|||||||
gapless_playback: true,
|
gapless_playback: true,
|
||||||
normalize_volume: true,
|
normalize_volume: true,
|
||||||
volume_level: VolumeLevel::Loud,
|
volume_level: VolumeLevel::Loud,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&settings).unwrap();
|
let json = serde_json::to_string(&settings).unwrap();
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<!-- TRACES: UR-023, UR-029, UR-057 | DR-048, DR-077, DR-086 -->
|
<!-- TRACES: UR-023, UR-027, UR-029, UR-057 | DR-030, DR-048, DR-077, DR-086 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
import type {
|
import type {
|
||||||
AudioSettings,
|
AudioSettings,
|
||||||
CacheConfig,
|
CacheConfig,
|
||||||
|
EqPreset,
|
||||||
VideoSettings,
|
VideoSettings,
|
||||||
VolumeLevel,
|
VolumeLevel,
|
||||||
} from "$lib/api/bindings";
|
} from "$lib/api/bindings";
|
||||||
@@ -39,8 +40,21 @@
|
|||||||
gaplessPlayback: true,
|
gaplessPlayback: true,
|
||||||
normalizeVolume: false,
|
normalizeVolume: false,
|
||||||
volumeLevel: "normal",
|
volumeLevel: "normal",
|
||||||
|
equalizerEnabled: false,
|
||||||
|
equalizerBands: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Equalizer band centre-frequency labels (must match Rust EQ_BANDS order).
|
||||||
|
// Presentation only — the gain curves themselves come from the backend.
|
||||||
|
const EQ_BAND_LABELS = ["31", "62", "125", "250", "500", "1k", "2k", "4k", "8k", "16k"];
|
||||||
|
const EQ_GAIN_MIN = -12;
|
||||||
|
const EQ_GAIN_MAX = 12;
|
||||||
|
// Preset name → gain curve, fetched from the backend (domain data lives in Rust).
|
||||||
|
let eqPresets = $state<[EqPreset, number[]][]>([]);
|
||||||
|
// Non-optional view of the bands for template bindings (the wire type marks
|
||||||
|
// equalizerBands optional via serde default; loadSettings guarantees it dense).
|
||||||
|
const eqBands = $derived(settings.equalizerBands ?? [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||||
|
|
||||||
let videoSettings = $state<VideoSettings>({
|
let videoSettings = $state<VideoSettings>({
|
||||||
autoPlayNextEpisode: true,
|
autoPlayNextEpisode: true,
|
||||||
autoPlayCountdownSeconds: 10,
|
autoPlayCountdownSeconds: 10,
|
||||||
@@ -86,14 +100,21 @@
|
|||||||
try {
|
try {
|
||||||
loading = true;
|
loading = true;
|
||||||
networkDetectionSupported = isNetworkDetectionSupported();
|
networkDetectionSupported = isNetworkDetectionSupported();
|
||||||
const [audioResult, videoResult, cacheResult] = await Promise.all([
|
const [audioResult, videoResult, cacheResult, presets] = await Promise.all([
|
||||||
commands.playerGetAudioSettings(),
|
commands.playerGetAudioSettings(),
|
||||||
commands.playerGetVideoSettings(),
|
commands.playerGetVideoSettings(),
|
||||||
getCacheConfig(),
|
getCacheConfig(),
|
||||||
|
commands.playerGetEqPresets(),
|
||||||
]);
|
]);
|
||||||
settings = audioResult;
|
// equalizerBands is optional on the wire (serde default); guarantee a
|
||||||
|
// dense 10-band array so the slider bindings are never undefined.
|
||||||
|
settings = {
|
||||||
|
...audioResult,
|
||||||
|
equalizerBands: audioResult.equalizerBands ?? [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||||
|
};
|
||||||
videoSettings = videoResult;
|
videoSettings = videoResult;
|
||||||
cacheConfig = cacheResult;
|
cacheConfig = cacheResult;
|
||||||
|
eqPresets = presets;
|
||||||
// Load cache stats in parallel but don't block on it
|
// Load cache stats in parallel but don't block on it
|
||||||
loadCacheStats();
|
loadCacheStats();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -210,6 +231,59 @@
|
|||||||
persistAudio();
|
persistAudio();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Equalizer (UR-027) ---
|
||||||
|
|
||||||
|
function handleEqToggle() {
|
||||||
|
settings.equalizerEnabled = !settings.equalizerEnabled;
|
||||||
|
persistAudio();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply a preset's gain curve (from the backend) to the bands.
|
||||||
|
function handleEqPreset(gains: number[]) {
|
||||||
|
settings.equalizerBands = [...gains];
|
||||||
|
persistAudio();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live-update a single band while dragging; persist on release (change).
|
||||||
|
function handleEqBandInput(index: number, e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
const bands = [...eqBands];
|
||||||
|
bands[index] = parseFloat(target.value);
|
||||||
|
settings.equalizerBands = bands;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEqBandChange(index: number, e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
const bands = [...eqBands];
|
||||||
|
bands[index] = parseFloat(target.value);
|
||||||
|
settings.equalizerBands = bands;
|
||||||
|
persistAudio();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The name of the preset whose curve matches the current bands, or null
|
||||||
|
// ("Custom"). Presentation-only label — the backend defines the curves.
|
||||||
|
const activeEqPreset = $derived.by<EqPreset | null>(() => {
|
||||||
|
const eq = eqBands;
|
||||||
|
for (const [name, gains] of eqPresets) {
|
||||||
|
if (gains.length === eq.length && gains.every((g, i) => g === eq[i])) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Human labels for preset chips.
|
||||||
|
const EQ_PRESET_LABELS: Record<EqPreset, string> = {
|
||||||
|
flat: "Flat",
|
||||||
|
rock: "Rock",
|
||||||
|
pop: "Pop",
|
||||||
|
jazz: "Jazz",
|
||||||
|
classical: "Classical",
|
||||||
|
bassBoost: "Bass Boost",
|
||||||
|
trebleBoost: "Treble Boost",
|
||||||
|
vocal: "Vocal",
|
||||||
|
};
|
||||||
|
|
||||||
function handleAutoPlayToggle() {
|
function handleAutoPlayToggle() {
|
||||||
videoSettings.autoPlayNextEpisode = !videoSettings.autoPlayNextEpisode;
|
videoSettings.autoPlayNextEpisode = !videoSettings.autoPlayNextEpisode;
|
||||||
persistVideo();
|
persistVideo();
|
||||||
@@ -419,6 +493,84 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Equalizer (UR-027) -->
|
||||||
|
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-white">Equalizer</h2>
|
||||||
|
<p class="text-sm text-gray-400 mt-1">
|
||||||
|
Shape the sound with presets or custom bands (Linux)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={handleEqToggle}
|
||||||
|
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {settings.equalizerEnabled
|
||||||
|
? 'bg-[var(--color-jellyfin)]'
|
||||||
|
: 'bg-gray-600'}"
|
||||||
|
aria-label="Toggle equalizer"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {settings.equalizerEnabled
|
||||||
|
? 'translate-x-7'
|
||||||
|
: 'translate-x-1'}"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if settings.equalizerEnabled}
|
||||||
|
<!-- Preset chips -->
|
||||||
|
<div class="pt-4 border-t border-gray-700">
|
||||||
|
<p class="text-sm font-medium text-gray-300 mb-3">Presets</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each eqPresets as [name, gains] (name)}
|
||||||
|
<button
|
||||||
|
onclick={() => handleEqPreset(gains)}
|
||||||
|
class="px-3 py-1.5 rounded-full text-sm transition-all {activeEqPreset ===
|
||||||
|
name
|
||||||
|
? 'bg-[var(--color-jellyfin)] text-white'
|
||||||
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
||||||
|
>
|
||||||
|
{EQ_PRESET_LABELS[name]}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if activeEqPreset === null}
|
||||||
|
<span
|
||||||
|
class="px-3 py-1.5 rounded-full text-sm bg-[var(--color-jellyfin)] text-white"
|
||||||
|
>
|
||||||
|
Custom
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Band sliders -->
|
||||||
|
<div class="pt-4 border-t border-gray-700">
|
||||||
|
<p class="text-sm font-medium text-gray-300 mb-4">Bands (dB)</p>
|
||||||
|
<div class="flex justify-between gap-1 sm:gap-2">
|
||||||
|
{#each EQ_BAND_LABELS as label, i (label)}
|
||||||
|
<div class="flex flex-col items-center gap-2 flex-1 min-w-0">
|
||||||
|
<span class="text-xs text-gray-400 tabular-nums">
|
||||||
|
{eqBands[i] > 0 ? "+" : ""}{eqBands[i]}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={EQ_GAIN_MIN}
|
||||||
|
max={EQ_GAIN_MAX}
|
||||||
|
step="1"
|
||||||
|
value={eqBands[i]}
|
||||||
|
oninput={(e) => handleEqBandInput(i, e)}
|
||||||
|
onchange={(e) => handleEqBandChange(i, e)}
|
||||||
|
class="eq-slider"
|
||||||
|
aria-label="{label} Hz gain"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-gray-500">{label}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Video Playback Settings -->
|
<!-- Video Playback Settings -->
|
||||||
<div class="border-t border-gray-700 pt-6">
|
<div class="border-t border-gray-700 pt-6">
|
||||||
<h2 class="text-2xl font-bold text-white mb-4">Video Playback</h2>
|
<h2 class="text-2xl font-bold text-white mb-4">Video Playback</h2>
|
||||||
@@ -746,8 +898,8 @@
|
|||||||
album playback
|
album playback
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Normalization</strong> uses ReplayGain tags and real-time
|
<strong>Normalization</strong> evens out loudness between tracks
|
||||||
loudnorm filtering
|
in real time, toward your selected level
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -756,3 +908,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Vertical EQ band sliders. `appearance: slider-vertical` is deprecated;
|
||||||
|
use writing-mode which is the supported path in modern WebKit/Chromium. */
|
||||||
|
.eq-slider {
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
direction: rtl;
|
||||||
|
width: 8px;
|
||||||
|
height: 96px;
|
||||||
|
accent-color: var(--color-jellyfin);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user