diff --git a/docs/requirements.md b/docs/requirements.md index 0115c4b1..98c03c88 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -37,7 +37,7 @@ For a narrative overview of the system design, see | UR-024 | View recently added content on server | Medium | 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-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-029 | Toggle between grid and list view in library | 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-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-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-018 | libmpv subtitle rendering and selection | Playback | UR-020 | 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-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 | @@ -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-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-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-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 | @@ -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-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-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/?episode=`; 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-056 | - | DR-085 | | 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-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-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 diff --git a/docs/specs/audio-equalizer.md b/docs/specs/audio-equalizer.md new file mode 100644 index 00000000..86f0e6ac --- /dev/null +++ b/docs/specs/audio-equalizer.md @@ -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, +``` + +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)>` (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` 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. diff --git a/docs/traceability.md b/docs/traceability.md index 8de7a18b..0cad89fe 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -1,32 +1,32 @@ # Code Traceability Matrix -**Generated:** 7/24/2026, 10:21:18 PM +**Generated:** 7/24/2026, 10:45:36 PM ## Summary -- **Total Files Scanned:** 284 -- **Total TRACES Found:** 269 +- **Total Files Scanned:** 287 +- **Total TRACES Found:** 294 - **Requirements Covered:** - - User Requirements (UR): 53 - - Integration Requirements (IR): 14 - - Development Requirements (DR): 77 + - User Requirements (UR): 55 + - Integration Requirements (IR): 15 + - Development Requirements (DR): 79 - Jellyfin API Requirements (JA): 24 ## Requirements by Type ### User Requirements (UR) ``` -UR-002, UR-003, UR-004, UR-005, UR-007, UR-008, UR-009, UR-010, UR-011, UR-012, UR-013, UR-014, UR-015, UR-016, UR-017, UR-018, UR-019, UR-020, UR-021, UR-022, UR-023, UR-024, UR-025, UR-026, UR-028, UR-029, UR-030, UR-031, UR-032, UR-033, UR-034, UR-035, UR-036, UR-038, UR-039, UR-040, UR-041, UR-042, UR-043, UR-044, UR-045, UR-046, UR-047, UR-048, UR-049, UR-050, UR-051, UR-052, UR-053, UR-054, UR-055, UR-056, UR-057 +UR-002, UR-003, UR-004, UR-005, UR-007, UR-008, UR-009, UR-010, UR-011, UR-012, UR-013, UR-014, UR-015, UR-016, UR-017, UR-018, UR-019, UR-020, UR-021, UR-022, UR-023, UR-024, UR-025, UR-026, UR-027, UR-028, UR-029, UR-030, UR-031, UR-032, UR-033, UR-034, UR-035, UR-036, UR-038, UR-039, UR-040, UR-041, UR-042, UR-043, UR-044, UR-045, UR-046, UR-047, UR-048, UR-049, UR-050, UR-051, UR-052, UR-053, UR-054, UR-055, UR-056, UR-057, UR-058 ``` ### Integration Requirements (IR) ``` -IR-003, IR-004, IR-009, IR-010, IR-011, IR-012, IR-013, IR-014, IR-015, IR-023, IR-025, IR-026, IR-027, IR-028 +IR-003, IR-004, IR-009, IR-010, IR-011, IR-012, IR-013, IR-014, IR-015, IR-020, IR-023, IR-025, IR-026, IR-027, IR-028 ``` ### Development Requirements (DR) ``` -DR-001, DR-002, DR-003, DR-004, DR-005, DR-006, DR-007, DR-009, DR-010, DR-011, DR-012, DR-013, DR-014, DR-015, DR-016, DR-017, DR-018, DR-020, DR-021, DR-022, DR-023, DR-024, DR-025, DR-026, DR-027, DR-028, DR-029, DR-032, DR-033, DR-034, DR-035, DR-036, DR-037, DR-038, DR-039, DR-040, DR-041, DR-043, DR-044, DR-045, DR-047, DR-048, DR-049, DR-050, DR-051, DR-052, DR-053, DR-054, DR-055, DR-056, DR-057, DR-058, DR-059, DR-060, DR-061, DR-062, DR-063, DR-064, DR-065, DR-066, DR-067, DR-068, DR-069, DR-070, DR-074, DR-075, DR-076, DR-077, DR-078, DR-079, DR-080, DR-081, DR-082, DR-083, DR-084, DR-085, DR-086 +DR-001, DR-002, DR-003, DR-004, DR-005, DR-006, DR-007, DR-009, DR-010, DR-011, DR-012, DR-013, DR-014, DR-015, DR-016, DR-017, DR-018, DR-020, DR-021, DR-022, DR-023, DR-024, DR-025, DR-026, DR-027, DR-028, DR-029, DR-030, DR-032, DR-033, DR-034, DR-035, DR-036, DR-037, DR-038, DR-039, DR-040, DR-041, DR-043, DR-044, DR-045, DR-047, DR-048, DR-049, DR-050, DR-051, DR-052, DR-053, DR-054, DR-055, DR-056, DR-057, DR-058, DR-059, DR-060, DR-061, DR-062, DR-063, DR-064, DR-065, DR-066, DR-067, DR-068, DR-069, DR-070, DR-074, DR-075, DR-076, DR-077, DR-078, DR-079, DR-080, DR-081, DR-082, DR-083, DR-084, DR-085, DR-086, DR-087 ``` ### Jellyfin API Requirements (JA) @@ -153,6 +153,35 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 3 - **Context:** `Unknown` +### IR-020 + +**Locations:** 8 file(s) + +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L584) + - **Line:** 584 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L604) + - **Line:** 604 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L673) + - **Line:** 673 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L685) + - **Line:** 685 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L759) + - **Line:** 759 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/player/settings.rs`](src-tauri/src/commands/player/settings.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L1) + - **Line:** 1 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L33) + - **Line:** 33 + - **Context:** `Unknown` + ### IR-023 **Locations:** 1 file(s) @@ -197,11 +226,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 17 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2213) - - **Line:** 2213 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2240) + - **Line:** 2240 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2270) - - **Line:** 2270 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2297) + - **Line:** 2297 - **Context:** `Unknown` - **File:** [`src/lib/stores/player.ts`](src/lib/stores/player.ts#L8) - **Line:** 8 @@ -233,18 +262,18 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/player/index.ts`](src/lib/player/index.ts#L15) - **Line:** 15 - **Context:** `Unknown` -- **File:** [`src-tauri/src/player/events.rs`](src-tauri/src/player/events.rs#L6) - - **Line:** 6 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/player/events.rs`](src-tauri/src/player/events.rs#L23) - - **Line:** 23 - - **Context:** `Unknown` - **File:** [`src-tauri/src/player/state.rs`](src-tauri/src/player/state.rs#L7) - **Line:** 7 - **Context:** `Unknown` - **File:** [`src-tauri/src/player/state.rs`](src-tauri/src/player/state.rs#L25) - **Line:** 25 - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/events.rs`](src-tauri/src/player/events.rs#L6) + - **Line:** 6 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/events.rs`](src-tauri/src/player/events.rs#L23) + - **Line:** 23 + - **Context:** `Unknown` - **File:** [`src-tauri/src/commands/player/mod.rs`](src-tauri/src/commands/player/mod.rs#L1) - **Line:** 1 - **Context:** `Unknown` @@ -253,8 +282,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 2 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2091) - - **Line:** 2091 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2118) + - **Line:** 2118 - **Context:** `export type PlaybackProgress = { itemId: string;` - **File:** [`src-tauri/src/player/media.rs`](src-tauri/src/player/media.rs#L42) - **Line:** 42 @@ -264,8 +293,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 2 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2195) - - **Line:** 2195 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2222) + - **Line:** 2222 - **Context:** `Unknown` - **File:** [`src-tauri/src/player/media.rs`](src-tauri/src/player/media.rs#L124) - **Line:** 124 @@ -273,14 +302,20 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, ### DR-004 -**Locations:** 5 file(s) +**Locations:** 9 file(s) +- **File:** [`src/lib/services/webviewAudio.ts`](src/lib/services/webviewAudio.ts#L18) + - **Line:** 18 + - **Context:** `Unknown` - **File:** [`src/lib/player/adapters/index.ts`](src/lib/player/adapters/index.ts#L11) - **Line:** 11 - **Context:** `Unknown` - **File:** [`src/lib/player/adapters/nativeAdapter.ts`](src/lib/player/adapters/nativeAdapter.ts#L16) - **Line:** 16 - **Context:** `Unknown` +- **File:** [`src/lib/player/adapters/webviewAudioAdapter.ts`](src/lib/player/adapters/webviewAudioAdapter.ts#L18) + - **Line:** 18 + - **Context:** `Unknown` - **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L39) - **Line:** 39 - **Context:** `pub fn playback_failed>(message: S) -> Self {` @@ -290,13 +325,19 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L251) - **Line:** 251 - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/webview_audio_backend.rs`](src-tauri/src/player/webview_audio_backend.rs#L25) + - **Line:** 25 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/webview_audio_backend.rs`](src-tauri/src/player/webview_audio_backend.rs#L184) + - **Line:** 184 + - **Context:** `Unknown` ### DR-005 **Locations:** 13 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2455) - - **Line:** 2455 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2494) + - **Line:** 2494 - **Context:** `export type RemoteSessionStatus = { position: number; duration: number...` - **File:** [`src/lib/stores/queue.test.ts`](src/lib/stores/queue.test.ts#L4) - **Line:** 4 @@ -431,11 +472,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 9 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L521) - - **Line:** 521 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L531) + - **Line:** 531 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L537) - - **Line:** 537 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L547) + - **Line:** 547 - **Context:** `Unknown` - **File:** [`src/lib/stores/library.ts`](src/lib/stores/library.ts#L2) - **Line:** 2 @@ -694,8 +735,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 18 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2270) - - **Line:** 2270 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2297) + - **Line:** 2297 - **Context:** `Unknown` - **File:** [`src/lib/utils/duration.test.ts`](src/lib/utils/duration.test.ts#L4) - **Line:** 4 @@ -753,8 +794,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 9 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2497) - - **Line:** 2497 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2536) + - **Line:** 2536 - **Context:** `export type SessionInfo = { id?: string | null; userId?: string | null...` - **File:** [`src/lib/components/player/SleepTimerModal.svelte`](src/lib/components/player/SleepTimerModal.svelte#L1) - **Line:** 1 @@ -781,6 +822,50 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 3 - **Context:** `Unknown` +### DR-030 + +**Locations:** 13 file(s) + +- **File:** [`src/routes/settings/+page.svelte`](src/routes/settings/+page.svelte#L1) + - **Line:** 1 + - **Context:** `Unknown` +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L181) + - **Line:** 181 + - **Context:** `Unknown` +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1773) + - **Line:** 1773 + - **Context:** `export type DownloadsResponse = { downloads: DownloadInfo[]; stats: Do...` +- **File:** [`src-tauri/src/commands/player/settings.rs`](src-tauri/src/commands/player/settings.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/player/settings.rs`](src-tauri/src/commands/player/settings.rs#L33) + - **Line:** 33 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L1) + - **Line:** 1 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L33) + - **Line:** 33 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L46) + - **Line:** 46 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L140) + - **Line:** 140 + - **Context:** `pub fn with_crossfade_clamped(mut self) -> Self {` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L200) + - **Line:** 200 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L229) + - **Line:** 229 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L256) + - **Line:** 256 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L267) + - **Line:** 267 + - **Context:** `Unknown` + ### DR-032 **Locations:** 1 file(s) @@ -824,8 +909,23 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, ### DR-036 -**Locations:** 2 file(s) +**Locations:** 7 file(s) +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L584) + - **Line:** 584 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L638) + - **Line:** 638 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L704) + - **Line:** 704 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L714) + - **Line:** 714 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L759) + - **Line:** 759 + - **Context:** `Unknown` - **File:** [`src-tauri/src/commands/player/settings.rs`](src-tauri/src/commands/player/settings.rs#L3) - **Line:** 3 - **Context:** `Unknown` @@ -908,7 +1008,7 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, ### DR-039 -**Locations:** 9 file(s) +**Locations:** 8 file(s) - **File:** [`src/routes/library/music/+page.svelte`](src/routes/library/music/+page.svelte#L1) - **Line:** 1 @@ -919,9 +1019,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/library/tv/+page.svelte`](src/routes/library/tv/+page.svelte#L1) - **Line:** 1 - **Context:** `Unknown` -- **File:** [`src/lib/components/library/MediaCard.svelte`](src/lib/components/library/MediaCard.svelte#L37) - - **Line:** 37 - - **Context:** `Unknown` - **File:** [`src/lib/components/home/Carousel.svelte`](src/lib/components/home/Carousel.svelte#L1) - **Line:** 1 - **Context:** `Unknown` @@ -991,8 +1088,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 13 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2270) - - **Line:** 2270 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2297) + - **Line:** 2297 - **Context:** `Unknown` - **File:** [`src/lib/stores/nextEpisode.ts`](src/lib/stores/nextEpisode.ts#L9) - **Line:** 9 @@ -1018,15 +1115,15 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/services/playerEvents.ts`](src/lib/services/playerEvents.ts#L340) - **Line:** 340 - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/autoplay.rs`](src-tauri/src/player/autoplay.rs#L2) + - **Line:** 2 + - **Context:** `Unknown` - **File:** [`src-tauri/src/player/events.rs`](src-tauri/src/player/events.rs#L6) - **Line:** 6 - **Context:** `Unknown` - **File:** [`src-tauri/src/player/events.rs`](src-tauri/src/player/events.rs#L23) - **Line:** 23 - **Context:** `Unknown` -- **File:** [`src-tauri/src/player/autoplay.rs`](src-tauri/src/player/autoplay.rs#L2) - - **Line:** 2 - - **Context:** `Unknown` - **File:** [`src-tauri/src/commands/player/timers.rs`](src-tauri/src/commands/player/timers.rs#L3) - **Line:** 3 - **Context:** `Unknown` @@ -1300,17 +1397,17 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 17 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L938) - - **Line:** 938 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L948) + - **Line:** 948 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L949) - - **Line:** 949 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L959) + - **Line:** 959 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1938) - - **Line:** 1938 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1965) + - **Line:** 1965 - **Context:** `export type MergedMediaItem = { id: string; title: string; artist: str...` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1947) - - **Line:** 1947 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1974) + - **Line:** 1974 - **Context:** `export type NetworkStateWrapperArg = { networkType: NetworkType; unmet...` - **File:** [`src/lib/stores/downloads.ts`](src/lib/stores/downloads.ts#L81) - **Line:** 81 @@ -1445,11 +1542,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/downloads/+page.svelte`](src/routes/downloads/+page.svelte#L14) - **Line:** 14 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1211) - - **Line:** 1211 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1221) + - **Line:** 1221 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1219) - - **Line:** 1219 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1229) + - **Line:** 1229 - **Context:** `Unknown` - **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L97) - **Line:** 97 @@ -1510,11 +1607,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/downloads/+page.svelte`](src/routes/downloads/+page.svelte#L14) - **Line:** 14 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L795) - - **Line:** 795 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L805) + - **Line:** 805 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1219) - - **Line:** 1219 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1229) + - **Line:** 1229 - **Context:** `Unknown` - **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L105) - **Line:** 105 @@ -1583,11 +1680,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/downloads/+page.svelte`](src/routes/downloads/+page.svelte#L14) - **Line:** 14 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1227) - - **Line:** 1227 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1237) + - **Line:** 1237 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1699) - - **Line:** 1699 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1718) + - **Line:** 1718 - **Context:** `Unknown` - **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L113) - **Line:** 113 @@ -1634,6 +1731,23 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 1 - **Context:** `Unknown` +### DR-087 + +**Locations:** 4 file(s) + +- **File:** [`src/routes/library/[id]/+page.svelte`](src/routes/library/[id]/+page.svelte#L386) + - **Line:** 386 + - **Context:** `Unknown` +- **File:** [`src/routes/+page.svelte`](src/routes/+page.svelte#L62) + - **Line:** 62 + - **Context:** `Unknown` +- **File:** [`src/routes/+page.svelte`](src/routes/+page.svelte#L86) + - **Line:** 86 + - **Context:** `Unknown` +- **File:** [`src/lib/components/library/MediaCard.svelte`](src/lib/components/library/MediaCard.svelte#L37) + - **Line:** 37 + - **Context:** `Unknown` + ### JA-001 **Locations:** 1 file(s) @@ -1897,8 +2011,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 9 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1309) - - **Line:** 1309 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1319) + - **Line:** 1319 - **Context:** `Unknown` - **File:** [`src/lib/api/repository-client.test.ts`](src/lib/api/repository-client.test.ts#L434) - **Line:** 434 @@ -1929,8 +2043,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 16 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2195) - - **Line:** 2195 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2222) + - **Line:** 2222 - **Context:** `Unknown` - **File:** [`src/lib/stores/connectivity.ts`](src/lib/stores/connectivity.ts#L10) - **Line:** 10 @@ -1980,17 +2094,20 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, ### UR-003 -**Locations:** 16 file(s) +**Locations:** 20 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2091) - - **Line:** 2091 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2118) + - **Line:** 2118 - **Context:** `export type PlaybackProgress = { itemId: string;` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2195) - - **Line:** 2195 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2222) + - **Line:** 2222 - **Context:** `Unknown` - **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L1) - **Line:** 1 - **Context:** `Unknown` +- **File:** [`src/lib/services/webviewAudio.ts`](src/lib/services/webviewAudio.ts#L18) + - **Line:** 18 + - **Context:** `Unknown` - **File:** [`src/lib/player/adapters/html5Adapter.ts`](src/lib/player/adapters/html5Adapter.ts#L19) - **Line:** 19 - **Context:** `Unknown` @@ -2006,21 +2123,30 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/player/adapters/types.ts`](src/lib/player/adapters/types.ts#L15) - **Line:** 15 - **Context:** `Unknown` +- **File:** [`src/lib/player/adapters/webviewAudioAdapter.ts`](src/lib/player/adapters/webviewAudioAdapter.ts#L18) + - **Line:** 18 + - **Context:** `Unknown` - **File:** [`src-tauri/src/player/mpv_backend_test.rs`](src-tauri/src/player/mpv_backend_test.rs#L8) - **Line:** 8 - **Context:** `Unknown` -- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L39) - - **Line:** 39 - - **Context:** `pub fn playback_failed>(message: S) -> Self {` -- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) - - **Line:** 245 - - **Context:** `Unknown` - **File:** [`src-tauri/src/player/media.rs`](src-tauri/src/player/media.rs#L42) - **Line:** 42 - **Context:** `Unknown` - **File:** [`src-tauri/src/player/media.rs`](src-tauri/src/player/media.rs#L124) - **Line:** 124 - **Context:** `pub enum MediaType {` +- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L39) + - **Line:** 39 + - **Context:** `pub fn playback_failed>(message: S) -> Self {` +- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) + - **Line:** 245 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/webview_audio_backend.rs`](src-tauri/src/player/webview_audio_backend.rs#L25) + - **Line:** 25 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/webview_audio_backend.rs`](src-tauri/src/player/webview_audio_backend.rs#L184) + - **Line:** 184 + - **Context:** `Unknown` - **File:** [`src-tauri/src/player/mod.rs`](src-tauri/src/player/mod.rs#L2) - **Line:** 2 - **Context:** `Unknown` @@ -2033,13 +2159,13 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, ### UR-004 -**Locations:** 13 file(s) +**Locations:** 15 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2091) - - **Line:** 2091 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2118) + - **Line:** 2118 - **Context:** `export type PlaybackProgress = { itemId: string;` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2195) - - **Line:** 2195 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2222) + - **Line:** 2222 - **Context:** `Unknown` - **File:** [`src/lib/components/player/AudioPlayer.svelte`](src/lib/components/player/AudioPlayer.svelte#L1) - **Line:** 1 @@ -2053,18 +2179,24 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src-tauri/src/player/mpv_backend_test.rs`](src-tauri/src/player/mpv_backend_test.rs#L8) - **Line:** 8 - **Context:** `Unknown` -- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L39) - - **Line:** 39 - - **Context:** `pub fn playback_failed>(message: S) -> Self {` -- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) - - **Line:** 245 - - **Context:** `Unknown` - **File:** [`src-tauri/src/player/media.rs`](src-tauri/src/player/media.rs#L42) - **Line:** 42 - **Context:** `Unknown` - **File:** [`src-tauri/src/player/media.rs`](src-tauri/src/player/media.rs#L124) - **Line:** 124 - **Context:** `pub enum MediaType {` +- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L39) + - **Line:** 39 + - **Context:** `pub fn playback_failed>(message: S) -> Self {` +- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) + - **Line:** 245 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/webview_audio_backend.rs`](src-tauri/src/player/webview_audio_backend.rs#L25) + - **Line:** 25 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/webview_audio_backend.rs`](src-tauri/src/player/webview_audio_backend.rs#L184) + - **Line:** 184 + - **Context:** `Unknown` - **File:** [`src-tauri/src/player/mod.rs`](src-tauri/src/player/mod.rs#L2) - **Line:** 2 - **Context:** `Unknown` @@ -2077,16 +2209,16 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, ### UR-005 -**Locations:** 54 file(s) +**Locations:** 58 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2213) - - **Line:** 2213 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2240) + - **Line:** 2240 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2270) - - **Line:** 2270 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2297) + - **Line:** 2297 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2455) - - **Line:** 2455 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2494) + - **Line:** 2494 - **Context:** `export type RemoteSessionStatus = { position: number; duration: number...` - **File:** [`src/lib/components/player/Controls.svelte`](src/lib/components/player/Controls.svelte#L1) - **Line:** 1 @@ -2175,6 +2307,9 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/services/playerEvents.ts`](src/lib/services/playerEvents.ts#L186) - **Line:** 186 - **Context:** `Unknown` +- **File:** [`src/lib/services/webviewAudio.ts`](src/lib/services/webviewAudio.ts#L18) + - **Line:** 18 + - **Context:** `Unknown` - **File:** [`src/lib/player/adapters/html5Adapter.ts`](src/lib/player/adapters/html5Adapter.ts#L19) - **Line:** 19 - **Context:** `Unknown` @@ -2187,14 +2322,26 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/player/adapters/types.ts`](src/lib/player/adapters/types.ts#L15) - **Line:** 15 - **Context:** `Unknown` +- **File:** [`src/lib/player/adapters/webviewAudioAdapter.ts`](src/lib/player/adapters/webviewAudioAdapter.ts#L18) + - **Line:** 18 + - **Context:** `Unknown` - **File:** [`src/lib/player/index.ts`](src/lib/player/index.ts#L15) - **Line:** 15 - **Context:** `Unknown` -- **File:** [`src-tauri/src/player/events.rs`](src-tauri/src/player/events.rs#L6) - - **Line:** 6 +- **File:** [`src-tauri/src/player/queue.rs`](src-tauri/src/player/queue.rs#L8) + - **Line:** 8 - **Context:** `Unknown` -- **File:** [`src-tauri/src/player/events.rs`](src-tauri/src/player/events.rs#L23) - - **Line:** 23 +- **File:** [`src-tauri/src/player/queue.rs`](src-tauri/src/player/queue.rs#L20) + - **Line:** 20 + - **Context:** `pub enum RepeatMode {` +- **File:** [`src-tauri/src/player/queue.rs`](src-tauri/src/player/queue.rs#L535) + - **Line:** 535 + - **Context:** `pub enum AddPosition {` +- **File:** [`src-tauri/src/player/state.rs`](src-tauri/src/player/state.rs#L7) + - **Line:** 7 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/state.rs`](src-tauri/src/player/state.rs#L25) + - **Line:** 25 - **Context:** `Unknown` - **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L42) - **Line:** 42 @@ -2211,24 +2358,21 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L58) - **Line:** 58 - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/events.rs`](src-tauri/src/player/events.rs#L6) + - **Line:** 6 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/events.rs`](src-tauri/src/player/events.rs#L23) + - **Line:** 23 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/webview_audio_backend.rs`](src-tauri/src/player/webview_audio_backend.rs#L25) + - **Line:** 25 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/webview_audio_backend.rs`](src-tauri/src/player/webview_audio_backend.rs#L184) + - **Line:** 184 + - **Context:** `Unknown` - **File:** [`src-tauri/src/player/mod.rs`](src-tauri/src/player/mod.rs#L2) - **Line:** 2 - **Context:** `Unknown` -- **File:** [`src-tauri/src/player/queue.rs`](src-tauri/src/player/queue.rs#L8) - - **Line:** 8 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/player/queue.rs`](src-tauri/src/player/queue.rs#L20) - - **Line:** 20 - - **Context:** `pub enum RepeatMode {` -- **File:** [`src-tauri/src/player/queue.rs`](src-tauri/src/player/queue.rs#L535) - - **Line:** 535 - - **Context:** `pub enum AddPosition {` -- **File:** [`src-tauri/src/player/state.rs`](src-tauri/src/player/state.rs#L7) - - **Line:** 7 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/player/state.rs`](src-tauri/src/player/state.rs#L25) - - **Line:** 25 - - **Context:** `Unknown` - **File:** [`src-tauri/src/commands/player/session.rs`](src-tauri/src/commands/player/session.rs#L3) - **Line:** 3 - **Context:** `Unknown` @@ -2307,11 +2451,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 15 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L521) - - **Line:** 521 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L531) + - **Line:** 531 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L537) - - **Line:** 537 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L547) + - **Line:** 547 - **Context:** `Unknown` - **File:** [`src/lib/stores/auth.test.ts`](src/lib/stores/auth.test.ts#L4) - **Line:** 4 @@ -2407,8 +2551,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 11 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2195) - - **Line:** 2195 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2222) + - **Line:** 2222 - **Context:** `Unknown` - **File:** [`src/lib/stores/downloads.ts`](src/lib/stores/downloads.ts#L2) - **Line:** 2 @@ -2578,8 +2722,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 12 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2270) - - **Line:** 2270 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2297) + - **Line:** 2297 - **Context:** `Unknown` - **File:** [`src/lib/services/playerEvents.test.ts`](src/lib/services/playerEvents.test.ts#L4) - **Line:** 4 @@ -2665,8 +2809,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/settings/+page.svelte`](src/routes/settings/+page.svelte#L1) - **Line:** 1 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2270) - - **Line:** 2270 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2297) + - **Line:** 2297 - **Context:** `Unknown` - **File:** [`src/lib/stores/nextEpisode.ts`](src/lib/stores/nextEpisode.ts#L9) - **Line:** 9 @@ -2695,15 +2839,15 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/services/playerEvents.ts`](src/lib/services/playerEvents.ts#L340) - **Line:** 340 - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/autoplay.rs`](src-tauri/src/player/autoplay.rs#L2) + - **Line:** 2 + - **Context:** `Unknown` - **File:** [`src-tauri/src/player/events.rs`](src-tauri/src/player/events.rs#L6) - **Line:** 6 - **Context:** `Unknown` - **File:** [`src-tauri/src/player/events.rs`](src-tauri/src/player/events.rs#L23) - **Line:** 23 - **Context:** `Unknown` -- **File:** [`src-tauri/src/player/autoplay.rs`](src-tauri/src/player/autoplay.rs#L2) - - **Line:** 2 - - **Context:** `Unknown` - **File:** [`src-tauri/src/player/mod.rs`](src-tauri/src/player/mod.rs#L2) - **Line:** 2 - **Context:** `Unknown` @@ -2776,11 +2920,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 16 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2270) - - **Line:** 2270 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2297) + - **Line:** 2297 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2497) - - **Line:** 2497 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2536) + - **Line:** 2536 - **Context:** `export type SessionInfo = { id?: string | null; userId?: string | null...` - **File:** [`src/lib/components/player/SleepTimerModal.svelte`](src/lib/components/player/SleepTimerModal.svelte#L1) - **Line:** 1 @@ -2809,15 +2953,15 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src-tauri/src/player/sleep_timer.rs`](src-tauri/src/player/sleep_timer.rs#L81) - **Line:** 81 - **Context:** `pub fn cancel(&mut self) {` +- **File:** [`src-tauri/src/player/autoplay.rs`](src-tauri/src/player/autoplay.rs#L2) + - **Line:** 2 + - **Context:** `Unknown` - **File:** [`src-tauri/src/player/events.rs`](src-tauri/src/player/events.rs#L6) - **Line:** 6 - **Context:** `Unknown` - **File:** [`src-tauri/src/player/events.rs`](src-tauri/src/player/events.rs#L23) - **Line:** 23 - **Context:** `Unknown` -- **File:** [`src-tauri/src/player/autoplay.rs`](src-tauri/src/player/autoplay.rs#L2) - - **Line:** 2 - - **Context:** `Unknown` - **File:** [`src-tauri/src/player/mod.rs`](src-tauri/src/player/mod.rs#L2) - **Line:** 2 - **Context:** `Unknown` @@ -2825,6 +2969,65 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 3 - **Context:** `Unknown` +### UR-027 + +**Locations:** 18 file(s) + +- **File:** [`src/routes/settings/+page.svelte`](src/routes/settings/+page.svelte#L1) + - **Line:** 1 + - **Context:** `Unknown` +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L181) + - **Line:** 181 + - **Context:** `Unknown` +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1773) + - **Line:** 1773 + - **Context:** `export type DownloadsResponse = { downloads: DownloadInfo[]; stats: Do...` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L584) + - **Line:** 584 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L604) + - **Line:** 604 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L673) + - **Line:** 673 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L685) + - **Line:** 685 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L759) + - **Line:** 759 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/player/settings.rs`](src-tauri/src/commands/player/settings.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/player/settings.rs`](src-tauri/src/commands/player/settings.rs#L33) + - **Line:** 33 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L1) + - **Line:** 1 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L33) + - **Line:** 33 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L46) + - **Line:** 46 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L140) + - **Line:** 140 + - **Context:** `pub fn with_crossfade_clamped(mut self) -> Self {` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L200) + - **Line:** 200 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L229) + - **Line:** 229 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L256) + - **Line:** 256 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L267) + - **Line:** 267 + - **Context:** `Unknown` + ### UR-028 **Locations:** 2 file(s) @@ -2869,7 +3072,7 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, ### UR-034 -**Locations:** 11 file(s) +**Locations:** 10 file(s) - **File:** [`src/routes/library/music/+page.svelte`](src/routes/library/music/+page.svelte#L1) - **Line:** 1 @@ -2880,9 +3083,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/library/tv/+page.svelte`](src/routes/library/tv/+page.svelte#L1) - **Line:** 1 - **Context:** `Unknown` -- **File:** [`src/lib/components/library/MediaCard.svelte`](src/lib/components/library/MediaCard.svelte#L37) - - **Line:** 37 - - **Context:** `Unknown` - **File:** [`src/lib/components/home/HeroBanner.svelte`](src/lib/components/home/HeroBanner.svelte#L1) - **Line:** 1 - **Context:** `Unknown` @@ -2965,8 +3165,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L51) - **Line:** 51 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1309) - - **Line:** 1309 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1319) + - **Line:** 1319 - **Context:** `Unknown` - **File:** [`src/lib/api/repository-client.test.ts`](src/lib/api/repository-client.test.ts#L434) - **Line:** 434 @@ -3152,17 +3352,17 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 17 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L938) - - **Line:** 938 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L948) + - **Line:** 948 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L949) - - **Line:** 949 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L959) + - **Line:** 959 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1938) - - **Line:** 1938 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1965) + - **Line:** 1965 - **Context:** `export type MergedMediaItem = { id: string; title: string; artist: str...` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1947) - - **Line:** 1947 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1974) + - **Line:** 1974 - **Context:** `export type NetworkStateWrapperArg = { networkType: NetworkType; unmet...` - **File:** [`src/lib/stores/downloads.ts`](src/lib/stores/downloads.ts#L81) - **Line:** 81 @@ -3225,14 +3425,14 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/downloads/+page.svelte`](src/routes/downloads/+page.svelte#L14) - **Line:** 14 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L795) - - **Line:** 795 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L805) + - **Line:** 805 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1211) - - **Line:** 1211 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1221) + - **Line:** 1221 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1219) - - **Line:** 1219 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1229) + - **Line:** 1229 - **Context:** `Unknown` - **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L97) - **Line:** 97 @@ -3311,11 +3511,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/downloads/+page.svelte`](src/routes/downloads/+page.svelte#L14) - **Line:** 14 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1227) - - **Line:** 1227 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1237) + - **Line:** 1237 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1699) - - **Line:** 1699 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1718) + - **Line:** 1718 - **Context:** `Unknown` - **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L113) - **Line:** 113 @@ -3368,6 +3568,23 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 1 - **Context:** `Unknown` +### UR-058 + +**Locations:** 4 file(s) + +- **File:** [`src/routes/library/[id]/+page.svelte`](src/routes/library/[id]/+page.svelte#L386) + - **Line:** 386 + - **Context:** `Unknown` +- **File:** [`src/routes/+page.svelte`](src/routes/+page.svelte#L62) + - **Line:** 62 + - **Context:** `Unknown` +- **File:** [`src/routes/+page.svelte`](src/routes/+page.svelte#L86) + - **Line:** 86 + - **Context:** `Unknown` +- **File:** [`src/lib/components/library/MediaCard.svelte`](src/lib/components/library/MediaCard.svelte#L37) + - **Line:** 37 + - **Context:** `Unknown` + ### IT-003 **Locations:** 1 file(s) @@ -3401,108 +3618,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 664 - **Context:** `Unknown` -### UT-010 - -**Locations:** 1 file(s) - -- **File:** [`src/lib/stores/downloads.test.ts`](src/lib/stores/downloads.test.ts#L2) - - **Line:** 2 - - **Context:** `Unknown` - -### UT-012 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/player/sleep_timer.rs`](src-tauri/src/player/sleep_timer.rs#L81) - - **Line:** 81 - - **Context:** `pub fn cancel(&mut self) {` - -### UT-024 - -**Locations:** 1 file(s) - -- **File:** [`src/lib/stores/downloads.test.ts`](src/lib/stores/downloads.test.ts#L2) - - **Line:** 2 - - **Context:** `Unknown` - -### UT-052 - -**Locations:** 1 file(s) - -- **File:** [`src/lib/stores/downloads.test.ts`](src/lib/stores/downloads.test.ts#L315) - - **Line:** 315 - - **Context:** `Unknown` - -### UT-060 - -**Locations:** 2 file(s) - -- **File:** [`src/lib/components/player/backgroundAudioHandoff.test.ts`](src/lib/components/player/backgroundAudioHandoff.test.ts#L10) - - **Line:** 10 - - **Context:** `Unknown` -- **File:** [`src/lib/components/player/backgroundAudioHandoff.ts`](src/lib/components/player/backgroundAudioHandoff.ts#L4) - - **Line:** 4 - - **Context:** `Unknown` - -### UT-061 - -**Locations:** 9 file(s) - -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L38) - - **Line:** 38 - - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L51) - - **Line:** 51 - - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1309) - - **Line:** 1309 - - **Context:** `Unknown` -- **File:** [`src/lib/api/repository-client.test.ts`](src/lib/api/repository-client.test.ts#L434) - - **Line:** 434 - - **Context:** `Unknown` -- **File:** [`src/lib/api/repository-client.test.ts`](src/lib/api/repository-client.test.ts#L451) - - **Line:** 451 - - **Context:** `Unknown` -- **File:** [`src/lib/player/backgroundAudio.commands.test.ts`](src/lib/player/backgroundAudio.commands.test.ts#L7) - - **Line:** 7 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/player/mod.rs`](src-tauri/src/commands/player/mod.rs#L574) - - **Line:** 574 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/player/mod.rs`](src-tauri/src/commands/player/mod.rs#L664) - - **Line:** 664 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L494) - - **Line:** 494 - - **Context:** `Unknown` - -### UT-066 - -**Locations:** 1 file(s) - -- **File:** [`src/lib/services/networkType.test.ts`](src/lib/services/networkType.test.ts#L4) - - **Line:** 4 - - **Context:** `Unknown` - -### UT-071 - -**Locations:** 1 file(s) - -- **File:** [`src/lib/utils/formatBytes.test.ts`](src/lib/utils/formatBytes.test.ts#L4) - - **Line:** 4 - - **Context:** `Unknown` - -### UR-016 - -**Locations:** 2 file(s) - -- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L62) - - **Line:** 62 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L251) - - **Line:** 251 - - **Context:** `Unknown` - ### IT-016 **Locations:** 1 file(s) @@ -3543,6 +3658,22 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 535 - **Context:** `pub enum AddPosition {` +### UT-010 + +**Locations:** 1 file(s) + +- **File:** [`src/lib/stores/downloads.test.ts`](src/lib/stores/downloads.test.ts#L2) + - **Line:** 2 + - **Context:** `Unknown` + +### UT-012 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/player/sleep_timer.rs`](src-tauri/src/player/sleep_timer.rs#L81) + - **Line:** 81 + - **Context:** `pub fn cancel(&mut self) {` + ### UT-014 **Locations:** 2 file(s) @@ -3653,6 +3784,14 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 157 - **Context:** `pub fn file_size(&self) -> Option {` +### UT-024 + +**Locations:** 1 file(s) + +- **File:** [`src/lib/stores/downloads.test.ts`](src/lib/stores/downloads.test.ts#L2) + - **Line:** 2 + - **Context:** `Unknown` + ### UT-025 **Locations:** 2 file(s) @@ -3664,17 +3803,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 157 - **Context:** `pub fn file_size(&self) -> Option {` -### UT-026 - -**Locations:** 2 file(s) - -- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) - - **Line:** 245 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L251) - - **Line:** 251 - - **Context:** `Unknown` - ### UT-027 **Locations:** 1 file(s) @@ -3687,14 +3815,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 1 file(s) -- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) - - **Line:** 245 - - **Context:** `Unknown` - -### UT-029 - -**Locations:** 1 file(s) - - **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) - **Line:** 245 - **Context:** `Unknown` @@ -3719,6 +3839,125 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 1 file(s) +- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) + - **Line:** 245 + - **Context:** `Unknown` + +### UT-051 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2889) + - **Line:** 2889 + - **Context:** `Unknown` + +### UT-052 + +**Locations:** 1 file(s) + +- **File:** [`src/lib/stores/downloads.test.ts`](src/lib/stores/downloads.test.ts#L315) + - **Line:** 315 + - **Context:** `Unknown` + +### UT-059 + +**Locations:** 3 file(s) + +- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L435) + - **Line:** 435 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2093) + - **Line:** 2093 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2134) + - **Line:** 2134 + - **Context:** `Unknown` + +### UT-060 + +**Locations:** 2 file(s) + +- **File:** [`src/lib/components/player/backgroundAudioHandoff.test.ts`](src/lib/components/player/backgroundAudioHandoff.test.ts#L10) + - **Line:** 10 + - **Context:** `Unknown` +- **File:** [`src/lib/components/player/backgroundAudioHandoff.ts`](src/lib/components/player/backgroundAudioHandoff.ts#L4) + - **Line:** 4 + - **Context:** `Unknown` + +### UT-061 + +**Locations:** 9 file(s) + +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L38) + - **Line:** 38 + - **Context:** `Unknown` +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L51) + - **Line:** 51 + - **Context:** `Unknown` +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1319) + - **Line:** 1319 + - **Context:** `Unknown` +- **File:** [`src/lib/api/repository-client.test.ts`](src/lib/api/repository-client.test.ts#L434) + - **Line:** 434 + - **Context:** `Unknown` +- **File:** [`src/lib/api/repository-client.test.ts`](src/lib/api/repository-client.test.ts#L451) + - **Line:** 451 + - **Context:** `Unknown` +- **File:** [`src/lib/player/backgroundAudio.commands.test.ts`](src/lib/player/backgroundAudio.commands.test.ts#L7) + - **Line:** 7 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/player/mod.rs`](src-tauri/src/commands/player/mod.rs#L574) + - **Line:** 574 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/player/mod.rs`](src-tauri/src/commands/player/mod.rs#L664) + - **Line:** 664 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L494) + - **Line:** 494 + - **Context:** `Unknown` + +### UT-066 + +**Locations:** 1 file(s) + +- **File:** [`src/lib/services/networkType.test.ts`](src/lib/services/networkType.test.ts#L4) + - **Line:** 4 + - **Context:** `Unknown` + +### UT-071 + +**Locations:** 1 file(s) + +- **File:** [`src/lib/utils/formatBytes.test.ts`](src/lib/utils/formatBytes.test.ts#L4) + - **Line:** 4 + - **Context:** `Unknown` + +### UR-016 + +**Locations:** 2 file(s) + +- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L62) + - **Line:** 62 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L251) + - **Line:** 251 + - **Context:** `Unknown` + +### UT-026 + +**Locations:** 2 file(s) + +- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) + - **Line:** 245 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L251) + - **Line:** 251 + - **Context:** `Unknown` + +### UT-029 + +**Locations:** 1 file(s) + - **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) - **Line:** 245 - **Context:** `Unknown` @@ -3739,14 +3978,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 4 - **Context:** `Unknown` -### UR-022 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/commands/player/settings.rs`](src-tauri/src/commands/player/settings.rs#L3) - - **Line:** 3 - - **Context:** `Unknown` - ### UR-031 **Locations:** 2 file(s) @@ -3771,8 +4002,23 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, ### UR-033 -**Locations:** 2 file(s) +**Locations:** 7 file(s) +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L584) + - **Line:** 584 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L638) + - **Line:** 638 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L704) + - **Line:** 704 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L714) + - **Line:** 714 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L759) + - **Line:** 759 + - **Context:** `Unknown` - **File:** [`src-tauri/src/commands/player/settings.rs`](src-tauri/src/commands/player/settings.rs#L3) - **Line:** 3 - **Context:** `Unknown` @@ -3780,38 +4026,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 1 - **Context:** `Unknown` -### UR-042 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/commands/auth.rs`](src-tauri/src/commands/auth.rs#L3) - - **Line:** 3 - - **Context:** `Unknown` - -### UR-044 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/commands/download/pinning.rs`](src-tauri/src/commands/download/pinning.rs#L3) - - **Line:** 3 - - **Context:** `Unknown` - -### UR-045 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/commands/download/smart_cache.rs`](src-tauri/src/commands/download/smart_cache.rs#L3) - - **Line:** 3 - - **Context:** `Unknown` - -### UR-046 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/commands/player/remote.rs`](src-tauri/src/commands/player/remote.rs#L3) - - **Line:** 3 - - **Context:** `Unknown` - ### UT-042 **Locations:** 1 file(s) @@ -3828,14 +4042,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 2027 - **Context:** `Unknown` -### UR-047 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/commands/storage/mod.rs`](src-tauri/src/commands/storage/mod.rs#L3) - - **Line:** 3 - - **Context:** `Unknown` - ### UT-044 **Locations:** 1 file(s) @@ -3844,28 +4050,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 136 - **Context:** `Unknown` -### UT-051 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2889) - - **Line:** 2889 - - **Context:** `Unknown` - -### UT-059 - -**Locations:** 3 file(s) - -- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L435) - - **Line:** 435 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2093) - - **Line:** 2093 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2134) - - **Line:** 2134 - - **Context:** `Unknown` - ### UT-067 **Locations:** 1 file(s) @@ -3930,4 +4114,124 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 2788 - **Context:** `Unknown` +### UT-079 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L200) + - **Line:** 200 + - **Context:** `Unknown` + +### UT-080 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L229) + - **Line:** 229 + - **Context:** `Unknown` + +### UT-081 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L256) + - **Line:** 256 + - **Context:** `Unknown` + +### UT-082 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L267) + - **Line:** 267 + - **Context:** `Unknown` + +### UT-083 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L673) + - **Line:** 673 + - **Context:** `Unknown` + +### UT-084 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L685) + - **Line:** 685 + - **Context:** `Unknown` + +### UT-085 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L704) + - **Line:** 704 + - **Context:** `Unknown` + +### UT-086 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L714) + - **Line:** 714 + - **Context:** `Unknown` + +### UT-087 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L759) + - **Line:** 759 + - **Context:** `Unknown` + +### UR-022 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/commands/player/settings.rs`](src-tauri/src/commands/player/settings.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` + +### UR-042 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/commands/auth.rs`](src-tauri/src/commands/auth.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` + +### UR-044 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/commands/download/pinning.rs`](src-tauri/src/commands/download/pinning.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` + +### UR-045 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/commands/download/smart_cache.rs`](src-tauri/src/commands/download/smart_cache.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` + +### UR-046 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/commands/player/remote.rs`](src-tauri/src/commands/player/remote.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` + +### UR-047 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/commands/storage/mod.rs`](src-tauri/src/commands/storage/mod.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` + diff --git a/docs/ux-flows.md b/docs/ux-flows.md index 4cd0ada8..1ce04e62 100644 --- a/docs/ux-flows.md +++ b/docs/ux-flows.md @@ -609,9 +609,12 @@ flowchart TB ``` An episode is **never** browsed as a bare `Episode` item page. Clicking an -episode anywhere navigates to `/library/?episode=`, so the -episode is always shown in the context of its series and the series' full -episode list is already loaded. +episode anywhere — a series' season list, a Home carousel (§5B.5), etc. — +navigates to `/library/?episode=`, so the episode is always +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 @@ -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 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/`) | Confirm → play now (`/player/`) | +| Episode | Series Episode Focus View (`/library/?episode=`, per §5B.1) | Confirm → play now (`/player/`) | +| Series / Season / Album / Artist / Playlist / Folder | Detail page (`/library/`) | Same as tap (no single "play now" target) | +| Channel / live leaf | Player (`/player/`) — 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 "" 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 diff --git a/src-tauri/src/commands/player/settings.rs b/src-tauri/src/commands/player/settings.rs index 4778e9d8..39e351b4 100644 --- a/src-tauri/src/commands/player/settings.rs +++ b/src-tauri/src/commands/player/settings.rs @@ -1,12 +1,12 @@ //! 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 super::{PlayerStateWrapper, VideoSettingsWrapper}; use crate::player::AutoplaySettings; -use crate::settings::{AudioSettings, VideoSettings}; +use crate::settings::{AudioSettings, EqPreset, VideoSettings}; #[tauri::command] #[specta::specta] @@ -14,13 +14,32 @@ pub async fn player_set_audio_settings( player: State<'_, PlayerStateWrapper>, settings: AudioSettings, ) -> Result { + // 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; controller - .set_audio_settings(&settings) + .set_audio_settings(&validated) .map_err(|e| e.to_string())?; 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)>, String> { + Ok(EqPreset::ALL + .iter() + .map(|p| (*p, p.gains().to_vec())) + .collect()) +} + #[tauri::command] #[specta::specta] pub async fn player_get_audio_settings( diff --git a/src-tauri/src/player/backend.rs b/src-tauri/src/player/backend.rs index b1280f47..61d675cf 100644 --- a/src-tauri/src/player/backend.rs +++ b/src-tauri/src/player/backend.rs @@ -332,6 +332,7 @@ mod tests { gapless_playback: false, normalize_volume: true, volume_level: VolumeLevel::Loud, + ..Default::default() }; backend.set_audio_settings(&settings).unwrap(); diff --git a/src-tauri/src/player/mpv_backend.rs b/src-tauri/src/player/mpv_backend.rs index 30e60575..7d929116 100644 --- a/src-tauri/src/player/mpv_backend.rs +++ b/src-tauri/src/player/mpv_backend.rs @@ -3,7 +3,7 @@ use super::events::{PlayerEventEmitter, PlayerStatusEvent}; use super::media::{MediaItem, MediaSource}; use super::state::PlayerState; 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::lock::MutexSafe; 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 volume normalization if needed 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 { + 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 { + 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 { fn drop(&mut self) { info!("[MpvBackend] Shutting down"); // 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}"); + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 84788141..3d35a343 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -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}; @@ -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 #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -38,6 +99,18 @@ pub struct AudioSettings { pub normalize_volume: bool, /// Target volume level for normalization 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, +} + +/// Flat 10-band curve — the default equalizer state. +fn default_eq_bands() -> Vec { + vec![0.0; EQ_BANDS.len()] } impl Default for AudioSettings { @@ -47,6 +120,8 @@ impl Default for AudioSettings { gapless_playback: true, normalize_volume: false, 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 } + + /// 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 @@ -101,6 +190,95 @@ mod tests { assert!(settings.gapless_playback); assert!(!settings.normalize_volume); 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] @@ -134,6 +312,7 @@ mod tests { gapless_playback: true, normalize_volume: true, volume_level: VolumeLevel::Loud, + ..Default::default() }; let json = serde_json::to_string(&settings).unwrap(); diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index ec04b69c..457da34d 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -1,10 +1,11 @@ - +