Commit Graph
4556 Commits
Author SHA1 Message Date
vicrodh 9b5a9ffaf5 feat(library): plumb excludeNetworkFolders through tree commands
v2_library_list_folder_children, v2_library_list_folder_tracks,
v2_library_list_folder_tracks_recursive accept and forward the
exclude-network-folders flag.

cc
2026-05-08 17:02:55 -06:00
vicrodh 1d68f7516e feat(qbz-library): network-folder filter on folder-tree primitives
list_folder_children, list_folder_tracks, list_folder_tracks_recursive
now apply the same network-folder exclusion that flat-mode search
honors, so the tree rail and recursive playback see the same source
of truth. Tests cover the on/off cases.

cc
2026-05-08 16:58:59 -06:00
vicrodh 632accbe40 fix(local-library): clear tree-mode state in bulk-action handlers
handleBulkPlayNext, handleBulkPlayLater, handleBulkAddToPlaylist now
call resetMultiSelect() so treeSelectMode and treeSelectedTracksById
are cleared alongside the existing flat-mode reset. Without this, the
BulkActionBar lingered with count 0 after firing from tree mode. The
tree-mode BulkActionBar's onClearSelection closure now delegates to
the same helper for consistency. resetMultiSelect was already
correct (it leaves trackPathById intact, which is what we want).

Also: warn when toggleTreeTrackSelection's cache-miss fallback hits a
track path with no parent folder, instead of silently returning.

cc
2026-05-08 16:37:15 -06:00
vicrodh 5e2670f59f test(qbz-library): empty-folder case for list_folder_tracks_recursive
Verify the recursive listing returns an empty Vec (not an error) when
the requested folder path has no descendants — frontend treats empty
as "nothing to play/queue" and skips the toast.

cc
2026-05-08 16:37:08 -06:00
vicrodh 3a09d8deb8 feat(local-library): recursive multi-select in tree mode
Folder-row checkbox in tree mode selects every descendant track via
v2_library_list_folder_tracks_recursive, deselects them on a second
click, and shows the standard indeterminate state when only some
descendants are selected. Track-kind rows in the tree honour the
same flow so users can deselect individual tracks from a partially-
selected folder. Both checkboxes share the existing selectedTrackIds
set, so the bulk-action bar (play next, play later, add to playlist)
operates on the union without any special-case handling.

Indeterminate state is computed cheaply: a SvelteMap<number, string>
caches track-id -> file_path (populated from the in-memory tracks
list and from each recursive fetch on toggle), and `countSelectedUnder`
walks the small selectedTrackIds set checking the prefix. Folder-row
state derives via a parent-supplied callback; the child reads the
prop reference of selectedTrackIds so reactivity propagates when the
parent reassigns the set.

Adds a dedicated treeSelectMode flag so the flat-mode tracks-tab
selection toggle stays independent. Renders the BulkActionBar at the
end of the tree-mode container when treeSelectMode is on. Adds a new
toast.failedSelectFolderTracks key across all five locales.

cc
2026-05-08 16:27:34 -06:00
vicrodh db2e0136d1 feat(library): expose v2_library_list_folder_tracks_recursive
Tauri wrapper for the qbz-library primitive added in the previous
commit. V2 cleanroom: direct call into qbz-library, no legacy
delegation. Registered in lib.rs alongside the existing folder-tree
commands.

cc
2026-05-08 16:17:26 -06:00
vicrodh 93a242e0d0 feat(qbz-library): add list_folder_tracks_recursive primitive
Returns full LocalTrack rows for every descendant under a folder path,
filtered by COALESCE(source, 'user') = 'user' so qobuz_download rows
never leak. Ordering is file_path ASC for stable on-disk reading
order across heterogeneous subtrees. Reuses escape_like_pattern so
literal '_' / '%' in path segments don't behave as LIKE wildcards.

Used by the tree-mode multi-select to populate selectedTrackIds when
the user ticks a folder-row checkbox; returns the full record (not
just IDs) so the frontend can build queue items without a second
round-trip.

Adds three unit tests covering the recursive walk, file_path
ordering, and special-character escape handling.

cc
2026-05-08 16:17:21 -06:00
vicrodh 8420a5ea0b feat(local-library): tree-mode search with path-parents preserved
When the user types in the search input while in tree mode, matching
folders/tracks stay visible and every ancestor path expands to keep
context. Empty branches collapse out. Clearing the query restores the
user's pre-search expand state. Implementation: in-memory contains over
the loaded `albums` array, debounced 200ms. The match expands the union
of matched paths and their ancestors in `searchExpandedPaths`, which
replaces `treeExpandedPaths` while search is active. Pre-search snapshot
is restored on clear. Tracks remain rendered under any visible folder.

cc
2026-05-08 16:00:27 -06:00
vicrodh a7b40e6e8a chore(local-library): handlePlayRecursive error toast + network-filter parity
- Surface a toast on playback failure so the user gets UI feedback
  rather than only a console error.
- Honor shouldExcludeNetworkFolders() for consistency with loadTracks
  and the rest of the view's search invocations.

cc
2026-05-08 15:55:33 -06:00
vicrodh 03d817f32a feat(local-library): two-column tree mode in Folders tab
Wires LocalLibraryFolderTree (left rail, 240px fixed) and the
LocalLibraryFolderDetail / album-detail flow (right pane) into a
two-column shell that mirrors the Artists tab CSS byte-for-byte.
View-mode segmented control on the Folders toolbar toggles between
Flat (the existing folder-grouped list) and Tree (the new hierarchical
view). The toggle is hidden when browsing Hidden Albums so that
sub-view keeps its existing flat rendering.

Selection routes the right pane: folder paths matching a known
album_group_key drive the existing handleAlbumClick flow (which sets
selectedAlbum and renders the top-level album-detail view); non-album
folders open LocalLibraryFolderDetail with subfolder cards plus
direct-children tracks. Empty-state when nothing is selected uses the
library.foldersTree.selectAFolder string.

Recursive play: handlePlayRecursive fetches all tracks via
v2_library_search (limit:0), client-side prefix-filters on file_path,
sorts by path and queues the result. Backend does not yet expose a
recursive-tracks-under-path command — flagged as a Task 7 trade-off
to revisit if telemetry shows tree-mode play stalling on large
libraries.

Mode persists in library_preferences.folders_view_mode via the
existing v2_set_library_folders_view_mode command, hydrated on mount
through v2_get_library_preferences. Flat mode rendering is
byte-for-byte unchanged: zero deletions in the existing markup, the
new tree shell wraps it via an outer if/else.

LibraryPreferences type extended with the optional folders_view_mode
field so TS strict-mode passes on the new prefs.folders_view_mode
read.

cc
2026-05-08 15:44:00 -06:00
vicrodh b4a7015ae7 chore(local-library): guard FolderDetail $effect against stale fetches
The folderPath snapshot existed but wasn't used to guard the async
writes. Rapid folder switches could land an older fetch's results on
top of the newer selection. Add three short stale-guards on .then,
.catch, and .finally so only the latest in-flight result wins.

cc
2026-05-08 15:33:16 -06:00
vicrodh 6742597cdf feat(local-library): LocalLibraryFolderDetail right-pane component
Renders the contents of a folder that is NOT a known album_group_key —
shows immediate subfolders + direct-children tracks side by side, with
a "Play all recursively" button for the whole folder. Used in the right
column of the upcoming tree-mode two-column layout (Task 7). Extracts
FolderTreeEntry to a shared $lib/types/folderTree.ts module so both
LocalLibraryFolderTree and FolderDetail import the same type.

cc
2026-05-08 15:28:17 -06:00
vicrodh 0e503b75e7 chore(local-library): address Task 5 review nits
- Keep self-import for recursion and replace the misleading "deprecated
  in Svelte 5" comment with a corrected rationale. svelte-check confirms
  `<svelte:self>` is in fact deprecated in Svelte 5 (svelte_self_deprecated)
  and self-import is the recommended replacement; the original code was
  right, only the comment was wrong.
- Switch the row role from `button` to `treeitem` so that `aria-selected`
  is valid (svelte-check rejects aria-selected on role=button) and add
  `aria-expanded` for folders. treeitem is also the semantically correct
  role for a tree row, so screen readers now announce both selection and
  expansion state.
- Add :focus-within parity for the play button so keyboard users see it
  when the row receives focus.

cc
2026-05-08 15:24:11 -06:00
vicrodh ad5d13a0c3 feat(local-library): LocalLibraryFolderTree recursive component
Lazy-loaded recursive tree-row component for the upcoming tree mode in
the Folders tab. Renders one node per call; children loaded on first
expand via v2_library_list_folder_children. ADR-001 compliant (no $t
inside $derived). Indentation by depth * 16px matches Poweramp-style
hierarchy. Recursion uses self-import pattern (svelte:self deprecated
in Svelte 5). Not yet wired into LocalLibraryView - that lands with
the two-column layout integration (Task 7).

cc
2026-05-08 15:13:54 -06:00
vicrodh 5c5fce2423 fix(i18n): relocate folder-tree keys to library.foldersTree namespace
Task 4's structural change of `library.folders` from string to object
broke LocalLibraryView.svelte:4337's dynamic tab label
($t(`library.${tab}`)) which expects a string. Move the new tree keys
to a sibling namespace `library.foldersTree.*` instead, and restore
`library.folders` to its original flat string value.

cc
2026-05-08 14:56:30 -06:00
vicrodh 107efc0f87 i18n: add library.folders.tree keys for tree-mode UI
Five locales (en, es, de, fr, pt). en and es are human-written in the
project's canonical Mexican neutral register; de/fr/pt are machine-
translated drafts flagged in qbz-nix-docs/i18n/pending-translations.md
for community review. Seven keys: modeLabel, flatLabel, selectAFolder,
treeFolderTracks (ICU plural), treeLoading, treeEmpty, playAllRecursive.

The flat library.folders string ("Folders" / "Carpetas" / etc.) was
migrated to an object; the original label is preserved at
library.folders.label. No source code currently references the key,
so the restructuring is non-breaking. "Track" is translated to match
each locale's existing convention (pista, Titel, titre, faixa).

cc
2026-05-08 14:51:57 -06:00
vicrodh a525bc92ec feat(library-prefs): add folders_view_mode for tree/flat selection
New persisted field on LibraryPreferences gating the upcoming Tree
sub-mode in the LocalLibrary Folders tab. Default flat preserves
existing behaviour for current installs. Migration adds the column
non-destructively. Setter v2_set_library_folders_view_mode exposes
the toggle to the frontend; the existing v2_get_library_preferences
returns the new field automatically via serde.

cc
2026-05-08 14:40:47 -06:00
vicrodh 24558d2a7d feat(library): expose v2_library_list_folder_children + folder_tracks
Tauri command wrappers for the qbz-library SQL primitives. V2 cleanroom:
both commands call directly into qbz-library, no legacy delegation.

cc
2026-05-08 14:21:07 -06:00
vicrodh 1bb353429c chore(qbz-library): address Task 1 review nits
- Replace the false "SQLite ORDER BY fragility" rationale on the
  Rust-side sort in list_folder_children with the actual reason
  (rows already in memory; Rust case-insensitive compare clearer
  than COLLATE NOCASE on a CASE-derived column).
- Drop the redundant ESCAPE '\\' clause on the literal '%/%' pattern
  in list_folder_tracks. The pattern contains no escape character;
  the clause was a copy-paste artifact from the user-input LIKE
  patterns where ESCAPE is required.

No behaviour change.

cc
2026-05-08 14:05:49 -06:00
vicrodh 8481f7e8b3 feat(qbz-library): add list_folder_children + list_folder_tracks queries
Two new SQL primitives that walk local_tracks.file_path to reconstruct
filesystem hierarchy without schema changes. Both filter
COALESCE(source, 'user') = 'user' so Plex and Qobuz downloads are
excluded from the eventual tree-mode view in the Folders tab. Both hit
idx_tracks_file_path for the LIKE prefix% portion; per-row level
extraction is in-memory over the prefix-narrowed candidate set. New
FolderTreeEntry enum carries the child rows. Unit tests cover folder/
track interleaving, source filter, special-character paths, and
direct-children vs recursive scoping.

cc
2026-05-08 13:48:26 -06:00
dependabot[bot] 04307d954d chore(deps): bump openssl from 0.10.78 to 0.10.79 in /src-tauri
Bumps [openssl](https://github.com/rust-openssl/rust-openssl) from 0.10.78 to 0.10.79.
- [Release notes](https://github.com/rust-openssl/rust-openssl/releases)
- [Commits](https://github.com/rust-openssl/rust-openssl/compare/openssl-v0.10.78...openssl-v0.10.79)

---
updated-dependencies:
- dependency-name: openssl
  dependency-version: 0.10.79
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-08 17:11:16 +00:00
vicrodh 3b93f35d0a fix(audio-settings): default backend_type to per-OS choice, migrate NULL rows
Issue #375: pause-then-resume sometimes leaves audio silent. Diagnosis
in user-reported logs:

  Synced audio settings to CoreBridge player: backend=None, exclusive=false
  ...
  Resume: reinitializing stream at 44100Hz/2ch
  Using default audio device          <- legacy CPAL path
  Using audio device: Default Audio Device
  Audio output initialized successfully  <- silent stream

backend_type=None falls through to init_device's legacy CPAL path which
races against rodio's DeviceSink drop on resume — CPAL hands us a new
default-device handle, but PipeWire is still tearing down the previous
sink, and the new handle ends up routed to a node that produces no
sound. CPAL doesn't fail the open, just gives a silent stream.

The "Sync audio settings on startup" toggle Rico29 was asked to enable
fires correctly (`sync_audio_on_startup=true` in his log) but only
re-syncs whatever is persisted. His persisted backend_type was still
NULL because he never explicitly picked a backend, so the sync was a
no-op.

Fix:

- Default::default() now sets backend_type to Some(AudioBackendType::default()),
  which picks PipeWire on Linux and SystemDefault elsewhere. New installs
  go directly onto the proper backend init path; the runtime still
  degrades gracefully if the chosen backend isn't available on the host
  (try_init_stream_with_backend returns Some(Err(_)) and the existing
  fallback warning fires).

- Migration backfills existing rows where backend_type IS NULL with the
  same per-OS default, serialized via serde_json so the row read path
  round-trips cleanly. macOS users were already getting effective
  SystemDefault dynamically at the call site (audio_settings.backend_type
  .or_else(...)) so behaviour there is unchanged; the migration just
  persists what the runtime was already substituting.

reset_all picks up the new default automatically because it reads
AudioSettings::default().backend_type.

cc
2026-05-08 00:12:59 -06:00
vicrodh 72a81490f4 fix(mixtape-detail): stabilize virtualized list scroll-up behavior
Three issues observed when fast-scrolling collection detail views
(reported on the Macross collection, ~67 items, list mode):

1. Horizontal scroll artifact at the bottom-right corner — same root
   cause as AlbumDetailView's: an absolutely-positioned artwork backdrop
   widens past 100% of the .detail-view. Add overflow-x: hidden alongside
   the existing overflow-y: auto.

2. Empty container when scroll position briefly exceeds the content
   tail. The virtualWindow math could compute firstRow * cols >= total
   during overscroll moments, slicing windowedItems to an empty array.
   Clamp firstRow / firstIdx to total - 1 so at least one row renders
   even when the scroll position has overshot.

3. The user-visible regression that started this thread: scrolling
   UP from deep in the list could not reach scrollTop = 0 even though
   the "Top" button worked. Two fixes layered together:

   a. overflow-anchor: none on .detail-view. Browser scroll-anchoring
      tries to preserve visual stability when content above the viewport
      changes height — but the virtualizer manages its own spacer
      heights, so the auto-anchoring nudges scrollTop in the opposite
      direction of user input. Disabling it lets the wheel input win.

   b. content-visibility: auto gated to expanded mode. Until now
      .item-block applied it unconditionally. The grid view's .grid-card
      already had this exact issue diagnosed and fixed (see the inline
      comment "doubling up with content-visibility: auto caused cards to
      re-eject and re-paint on scroll-back"); .item-block had been left
      behind. Layering native content-visibility on top of manual
      virtualization caused the browser to intermittently treat rendered
      rows as off-screen during fast scroll, snapping them to the
      contain-intrinsic-size hint for a frame, which the spacer math
      doesn't predict — producing the empty-viewport-with-stuck-scroll
      symptom. The .lazy class now scopes content-visibility to expanded
      mode, where virtualization is OFF and the optimization is needed.

Smoothness during very fast wheel scroll is still imperfect but the
viewport no longer goes blank and scroll-up reaches the top reliably.

cc
2026-05-08 00:01:40 -06:00
vicrodh d452838cd1 fix(album-detail): clip horizontal overflow from artwork backdrop
The .has-art-bg ::before pseudo-element renders the album artwork
heavily blurred at transform: scale(1.15) for the Qobuz/Feishin-style
header. Without overflow-x: hidden on the .album-detail container, the
15% horizontal upscale of the absolutely-positioned backdrop bleeds
past the right edge and surfaces a horizontal scrollbar on the page.
Add overflow-x: hidden to the existing overflow-y: auto so the blur
stays clipped without affecting vertical scroll.

cc
2026-05-08 00:01:16 -06:00
vicrodh a67e3c9217 Merge PR #391 (Vudgekek): macOS CoreAudio Exclusive Mode
Adds CoreAudio Hog Mode integration on macOS so Exclusive Mode actually
takes the device exclusively (previously the macOS path used the
Linux-shaped logic without any kernel-level lock). Pins the CoreAudio
device id through Hog Mode acquisition, sample-rate switching, and
stream creation so System Default doesn't drift mid-session. Linux
behaviour is unchanged.

Review asks (all four resolved by Vudgekek + afonsojramos):

- set_coreaudio_hardware_volume falls back to software volume on Err
  rather than reporting success, so knob-only DACs keep a working
  volume slider via engine.set_volume().
- CoreAudioExclusiveGuard snapshots the device's hardware volume on
  acquire and restores it before releasing Hog Mode, so leaving
  Exclusive Mode returns the DAC to the user's pre-Exclusive level.
  Guard is constructed before the FFI call so partial-acquire failures
  still trigger Drop and release.
- Linux unused-variable warnings on stream_opt and exclusive_guard
  cleaned up via cfg_attr(allow(unused_variables)) on the macOS-only
  guard plumbing. cargo check is clean.
- Silent shared-mode regression on macOS when backend init fails:
  init_device now sets_current_device(None) and returns None on
  exclusive_mode==true so the user sees an error instead of
  unknowingly playing in shared mode.

Bonus: 5f10b4ba adds real CoreAudio device-rate detection for macOS
Shared Mode, +90 lines in backend.rs.

Conflict resolution: single textual collision in
crates/qbz-audio/src/lib.rs (both branches added a pub use line).
Resolved by keeping both, alphabetical order — coreaudio_direct
re-export from pr-391, device_reservation re-export from pre-release.
All other auto-merges verified line-by-line: AlsaDirectStream's
DeviceReservation::acquire flow (alsa_direct.rs:60-97), the macOS
exclusive_mode guard (player/mod.rs:1077-1085 in qbz-player and the
mirror in src-tauri:1024), and CoreAudioExclusiveGuard's volume
snapshot/restore (coreaudio_direct.rs:351,369,373,388) all coexist
correctly because each branch touched different functions.

cargo check clean across all four crates. 9 device_reservation +
3 reserve_dac + 214 src-tauri unit tests pass.

cc
2026-05-07 19:09:37 -06:00
dependabot[bot] 7dcfb363fc chore(deps): bump tauri from 2.10.3 to 2.11.1 in /src-tauri
Bumps [tauri](https://github.com/tauri-apps/tauri) from 2.10.3 to 2.11.1.
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.10.3...tauri-v2.11.1)

---
updated-dependencies:
- dependency-name: tauri
  dependency-version: 2.11.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-07 23:20:08 +00:00
vicrodh d214125d1c feat(settings): expose reserve-DAC toggle in Audio settings
Mirrors the HiFi Wizard control (landing in Task 8) inline under
Exclusive Mode. Status row polls v2_get_dac_reservation_status every
5s while the row is rendered. Hidden when the selected device is not
a card-specific ALSA prefix (hw:, plughw:, front:, surround*, iec958:,
hdmi:). Six i18n keys consumed at settings.audio.reserveDac.*.

cc
2026-05-07 15:42:57 -06:00
vicrodh 4a8af7d316 i18n: add reserveDac keys for ALSA exclusive hardening UI
Five locales (en, es, de, fr, pt). English and Spanish are human-written
in the project's canonical Mexican neutral register; de/fr/pt are
machine-translated drafts. Pending-translations note recorded in
qbz-nix-docs/i18n/pending-translations.md (outside git scope).

Keys: settings.audio.reserveDac.{label, description, statusActive, statusContested, statusUnavailable, wirePlumberNote}.

UI surfaces (Settings mirror + HiFi Wizard) consume these in Tasks 7-8.

cc
2026-05-07 15:09:37 -06:00
vicrodh 11b46bbe9f chore(reservation): address Task 5 review feedback
- Document the Contested-vs-Unavailable classification limitation on
  the DacReservationStatus enum and its call site, deferring the
  stash-the-last-error refactor until Tasks 7-8 confirm the UI needs
  the distinction.
- Lift is_card_specific_device to pub(crate) in lib.rs and delete the
  duplicate in commands_v2/audio.rs to prevent the allow-list from
  diverging when new ALSA prefixes are added.
- Drop the misleading "or non-card device" clause from the
  apply_dac_reservation degraded-path log; non-card devices are
  filtered earlier and cannot reach this log line.

No behaviour change.

cc
2026-05-07 14:50:58 -06:00
Vudgekek 5cd76f53d4 Merge branch 'pre-release' into feature/external/macos-exclusive-mode 2026-05-07 15:34:44 -05:00
Vudgekek 5f10b4baa4 fix(audio): check real CoreAudio device rate with macOS Shared Mode 2026-05-07 14:51:51 -05:00
vicrodh 4797fec939 feat(reservation): per-process DAC reservation + Tauri commands
Lifetime B from the design spec: a long-lived DeviceReservation guard
held in AppState, gated by the reserve_dac_while_running setting.
Acquired at startup if the persisted flag is true and the selected
device targets a single ALSA card; reapplied when the user picks a
different DAC; released on toggle-off, on a switch to a non-card
device, or AppState drop.

DB persistence: ALTER TABLE migration adds the column, SELECT and
reset_all are extended, set_reserve_dac_while_running setter parallels
the established pattern for boolean audio settings.

Tauri commands (V2 cleanroom):
- v2_set_reserve_dac_while_running(enabled)  - persist + apply
- v2_get_dac_reservation_status() -> DacReservationStatus

Status enum mirrors the four cases in the spec: Inactive (toggle off,
or non-card-specific device), Active (we hold the bus name), Contested
(another app holds the device at higher priority - guard is None but
the toggle is on), Unavailable (D-Bus unreachable, degraded guard).

v2_set_audio_output_device gains an AppState dependency so DAC changes
trigger a reservation handover when the toggle is on.

UI surfaces (Settings mirror + HiFi Wizard) land in Tasks 7-8.

cc
2026-05-07 13:48:51 -06:00
vicrodh a1a056c4b9 feat(audio-settings): add reserve_dac_while_running flag
Opt-in flag for the per-process DAC reservation lifetime (Lifetime B
in the spec). Defaults to false; serde default ensures legacy settings
JSON files round-trip cleanly without the field. Task 5 will read this
flag in AppState startup and the v2_set_reserve_dac_while_running
Tauri command.

Strictly additive: no existing field changed, no behaviour change for
users with the flag at its default (false).

cc
2026-05-07 12:39:59 -06:00
vicrodh d2185f7a6b fix(qbz-audio): resolve ALSA card by id, not by long name
The CARD= parameter in ALSA device strings refers to the card's short
id (e.g. "C20", "HDMI", "Generic"), not its long descriptive name
("Cambridge Audio USB Audio 2.0"). The previous resolver called
Card::get_name() and silently swallowed errors, so every CARD=<id>
lookup failed and acquire() degraded gracefully -- meaning the
reservation has been non-functional for users with named-card device
strings since Task 3 landed.

Reported by the maintainer on Cambridge DacMagic Plus exposed as
front:CARD=C20,DEV=0, 2026-05-07. Smoke-test scenario 2 had been a
false positive: audio worked because we degraded silently, not because
the reservation succeeded.

In alsa-rs 0.10, Card::get_name() wraps snd_card_get_name and despite
the name returns the LONG descriptive string. The short id (what
aplay -l shows in brackets and what CARD= references) is exposed via
ctl::CardInfo::get_id() (snd_ctl_card_info_get_id). Open a Ctl per
card, read the id from its CardInfo, and surface ALSA enumeration
errors as ReservationError::AlsaError instead of unwrap_or_default()
into an empty string.

cc
2026-05-07 12:07:03 -06:00
vicrodh a4eb441c4e fix(qbz-audio): accept all ALSA plugin prefixes; degrade on parse failure
The previous parse_card_index only accepted hw: and plughw: prefixes,
which broke playback for users whose ALSA device string uses front:,
plughw:, surround*:, iec958:, hdmi:, etc. The plugin prefix is
irrelevant for D-Bus reservation purposes - what matters is the card
identifier in the args. Generalise the parser to extract the card
from any plugin prefix.

Defence in depth: when parse_card_index fails (unrecognised string,
no card identifier extractable, etc.), acquire() now logs a warning
and returns a degraded guard instead of propagating the error.
This preserves playback for non-card-targeting names like 'default'
and 'pulse' that are not meaningful for per-card reservation.

Reported on Cambridge DacMagic Plus exposed as front:CARD=C20,DEV=0,
2026-05-07.

cc
2026-05-07 11:10:12 -06:00
vicrodh 6b7ed75cc9 chore(qbz-audio): polish AlsaDirectStream reservation wiring
- Skip the 50 ms PipeWire-vacate margin on the degraded D-Bus path;
  no reservation transition happened, so no settle delay is needed.
- Add TODO(Task 5) breadcrumb for the app_device_name placeholder
  so Task 5's UI surfacing wires it to the user-facing DAC name.
- Soften the timing-claim comment on the sleep; the specific "~2-4 ms"
  was spec-cited, not measured in this commit.
- Lift the 50 ms literal into a named PIPEWIRE_VACATE_MARGIN const.

No behaviour change for the active-reservation path.

cc
2026-05-07 10:19:59 -06:00
vicrodh 92633dcf12 feat(qbz-audio): hold device reservation for AlsaDirectStream lifetime
Acquire org.freedesktop.ReserveDevice1.Audio<N> before opening the PCM,
hold for the entire stream lifetime, release on Drop after the PCM
closes. Closes the path where exclusive open would silently fight an
active PipeWire route.

Field order in AlsaDirectStream is significant: _reservation is the
last field so it drops AFTER the PCM closes, ensuring the kernel-level
exclusive lock is released before the D-Bus bus name is freed and
PipeWire reclaims the device.

This is the canonical Lifetime-A consumer the device_reservation
acquire doc-comment's tight-coupling rule allows: a DeviceReservation
created immediately before a real PCM::new() and held for as long as
the PCM is open. A 50 ms defensive sleep gives PipeWire time to vacate
the device after releasing the reservation.

cc
2026-05-07 10:09:07 -06:00
vicrodh 41b844cfa8 chore(qbz-audio): document tight-coupling rule and refactor contention proxy
- Document the load-bearing tight-coupling rule on acquire(): never
  construct a DeviceReservation without an immediate device consumer.
  Cambridge DacMagic Plus was stranded by a standalone reservation
  churn during initial Task 2 smoke testing on 2026-05-07.
- Document the Connection lifecycle: zbus does not pool session-bus
  connections, so acquire() pays a SASL handshake per call. Task 5
  will add acquire_with_connection() to reuse a long-lived bus.
- Refactor resolve_contention to open a single holder Proxy and read
  Priority + ApplicationName through it, instead of opening two.
- Specificity polish on the QBZ_APPLICATION_NAME dead-code comment;
  defensive-InQueue comment; release_name From-chain comment;
  clippy doc-list-overindentation fixes.

No behaviour change.

cc
2026-05-07 10:00:57 -06:00
vicrodh bc5d316e5a feat(qbz-audio): wire zbus client for ReserveDevice1 acquisition
Implements the full priority-resolution algorithm: request bus name
with DO_NOT_QUEUE; on EXISTS look up holder Priority via the
Properties interface; if our priority (5) is higher, call
RequestRelease and retry with REPLACE_EXISTING; if equal-or-lower,
return HigherPriorityHolder. AlreadyOwner is treated as success for
same-connection idempotency. Drop releases the bus name and logs
the ReleaseName reply variant.

D-Bus failure paths degrade to a no-op guard so playback continues
exactly as before in environments without a session bus.

Smoke-tested against PipeWire/WirePlumber via dbus-monitor.

cc
2026-05-07 09:43:37 -06:00
vicrodh f0ea0c88ec chore(qbz-audio): address Task 1 review feedback
- Add ReservationError::AlsaError variant; stop conflating ALSA
  enumeration errors with DbusError.
- Mirror ReservationError variant set on the non-Linux stub so cross-
  platform pattern matching compiles on every target.
- Add empty Drop to stub for RAII symmetry with the Linux variant.
- Collapse ReservationState to its real Task 1 shape (Degraded only);
  Task 2 reintroduces Active when it has real fields.
- Rewrite acquire() doc-comment to honestly describe Task 1's behaviour
  and hint at Task 2's full failure-mode set.

No behaviour change. Foundation commit for Task 2.

cc
2026-05-07 09:18:19 -06:00
vicrodh 4bc4213b97 feat(qbz-audio): add device-reservation module skeleton with parsers
Adds the new module that will host the org.freedesktop.ReserveDevice1
D-Bus client. This commit only ships the public surface, the parser
helpers (hw:N -> Audio<N> bus name, hw:CARD=name resolution) and unit
tests for them. acquire() currently returns a degraded guard; D-Bus
implementation lands in the next commit.

cc
2026-05-07 08:57:31 -06:00
Afonso Jorge Ramos 88e072a4ac fix(macos): harden CoreAudio exclusive stream handling
Address review feedback from the macOS Exclusive Mode PR.

Pin the resolved CoreAudio output device before acquiring Hog Mode so
System Default cannot drift between Hog Mode acquisition, sample-rate
switching, and stream creation.

Fall back to software volume when CoreAudio hardware volume is unavailable,
which keeps the volume slider usable on knob-only DACs.

Snapshot and restore CoreAudio hardware volume around Exclusive Mode so
leaving Exclusive Mode does not leave the output device at QBZ's last
hardware volume.

Refuse shared-mode fallback when macOS Exclusive Mode stream creation
fails, so the app does not silently play in non-exclusive mode while the UI
still reports Exclusive Mode.

Clean up Linux unused-variable warnings from the macOS-only guard plumbing.
2026-05-07 09:36:20 -05:00
vicrodh a705e4f297 Merge feature/internal/discord-rpc-v2 into pre-release
Discord Rich Presence (#336): when the user opts in, QBZ pushes the current
track as Listening status in Discord with cover, progress bar, and Get QBZ
button. Off by default, silent degrade when Discord is unavailable, sandbox
override block in Settings -> Integrations.

cc
2026-05-07 02:01:22 -06:00
vicrodh 3a0c2ab6ce build(flatpak): expose discord-ipc-0 socket for RPC
Adds --filesystem=xdg-run/discord-ipc-0 to the self-hosted Flatpak manifest
so Discord Rich Presence can reach the IPC socket from inside the sandbox.
The Flathub manifest is updated separately at release time.

Refs #336.

cc
2026-05-07 01:47:54 -06:00
vicrodh f2b0ce7071 feat(discord-rpc): settings UI with toggle and sandbox override block
Adds a Discord Rich Presence section to Settings -> Integrations:
- Toggle wired to discordRpcService (off by default, persisted in localStorage)
- Collapsible override block under the toggle with the flatpak override
  command and a Snap fallback note. Always shown - runtime detection isn't
  worth the complexity for a help block.

Refs #336.

cc
2026-05-07 01:42:42 -06:00
vicrodh 6b08cf6848 i18n(discord-rpc): add translation keys for 5 locales
Adds settings.integrations.discordRpc keys (label, description, overrideHeader,
flatpakNote, snapNote) to en/es/de/fr/pt for the Discord Rich Presence settings
toggle and sandbox override block.

Refs #336.

cc
2026-05-07 01:38:04 -06:00
vicrodh 232276f4fa feat(discord-rpc): wire initDiscordRpc into app boot
Calls discordRpcService.initDiscordRpc() once from +page.svelte onMount so
the backend state matches the persisted toggle and the playerStore subscriber
is registered when the user has opted in.

Refs #336.

cc
2026-05-07 01:35:19 -06:00
vicrodh 7531c2f092 feat(discord-rpc): frontend service with localStorage toggle and dedupe
Creates discordRpcService that:
- persists the user toggle in localStorage (qbz-discord-rpc-enabled, off by default)
- on enable, subscribes to playerStore and forwards (track_id, is_playing) transitions
- dedupes by (track_id, is_playing) so currentTime ticks don't spam the backend
- silently ignores backend errors (headless, sandbox-blocked, etc.)

Refs #336.

cc
2026-05-07 01:32:07 -06:00
vicrodh 82453b8fee feat(discord-rpc): backend module and Tauri commands
Adds src-tauri/src/discord_rpc.rs with DiscordRpcState (Mutex<Option<client>>
+ AtomicBool enabled) and three V2 commands:
- v2_discord_rpc_set_enabled
- v2_discord_rpc_update
- v2_discord_rpc_clear

Lazy connect on first update; silent degrade when Discord is unavailable.
Hardcoded Application ID 1501835855587708988 (public identifier).

Refs #336.

cc
2026-05-07 01:29:03 -06:00
vicrodh 2b5366eb06 chore(deps): add discord-rich-presence 0.2.4
For Discord Rich Presence integration (#336).

cc
2026-05-07 01:20:27 -06:00