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
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
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
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
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
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
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
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
- 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
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
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
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
- 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
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
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
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
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
- 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
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
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
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
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
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
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
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
- 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
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
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
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
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
- 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
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
- 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
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
- 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
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
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
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
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
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
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
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
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
Apply the album/artist detail backdrop overhaul:
- Blurred artwork background instead of a flat palette gradient
- Gradient extended to artist detail, sharing the existing toggle
- Image cache plumbed through extractPalette so artist images bypass
Qobuz CDN's missing CORS headers and Vibrant can read pixels
- Per-view height tuning so each fade lands above its own toolbar
- Artist jump-nav transparent at rest, gains its background only when
pinned (IntersectionObserver sentinel)
- Reactive text + action button overrides extended to artist view
cc
Promote the album/artist detail header gradient from a flat
palette-color fade to a Qobuz/Feishin-style blurred backdrop, and
extend it to the artist page.
- artworkPalette: route the URL through getCachedImageUrl before
handing it to Vibrant. Qobuz CDN sends Access-Control-Allow-Origin
for album covers but not artist images, which tainted the canvas
Vibrant uses and made pixel reads fail. The cache resolves to a
local asset:// path and primes the disk store for next time.
- AlbumDetailView and ArtistDetailView: ::before now renders the
artwork itself with filter:blur + saturate, masked to fade into the
theme background; ::after carries the contrast scrim so the blur
layer doesn't muddle it. The CSS variable --art-image-url carries
the locally-resolved URL so the backdrop image is served from disk
instead of being re-downloaded.
- Heights tuned per view: artist's header is taller (image + bio +
actions) than album's, so artist gets 340 × scale(1.1) and album
gets 280 × scale(1.15) so each fade lands above its respective
toolbar / JUMP TO bar.
- ArtistDetailView jump-nav: now transparent at rest and gains its
background only when stuck (IntersectionObserver against a 1px
sentinel placed right above it). Stops the bar from cutting a
flat-color stripe across the gradient when it's not pinned.
- Reactive text + action button overrides on .has-art-bg apply to
the artist page too: back button, bio text, bio toggle, and the
action-btn-circle family read in white-tinted variants over the
colored backdrop.
cc
Custom sort had two parallel selection systems: an inherent per-row
checkbox column (selectedTrackKeys) used solely to enable Move Up/Down
in a separate batch toolbar, and the toolbar's multiSelectMode for
bulk actions. They had different stores, different widths, and one
disabled the other. Consolidate onto multiSelectMode; drag and the
existing per-row reorder controls cover ordering, and the wrapper
propagates dragTrackIds for group moves.
- Drop selectedTrackKeys, isSelectionMode, toggleTrackSelection,
clearSelection, selectAllTracks, moveSelectedUp, moveSelectedDown
and the associated batch-controls toolbar / col-checkbox header
branch / inherent per-row checkbox.
- Move the multi-select checkbox out of TrackRow into the wrapper so
the column order in custom sort reads [checkbox][reorder][TrackRow];
TrackRow's internal track-checkbox is hidden via :global so its
click + keyboard handlers stay wired through selectable=true.
- Reserve 24px for the row-checkbox and the header col-select-all at
all times and animate opacity (180ms) instead of width. Toggling
select no longer reflows the columns to its right; the checkbox
fades inside a slot that was always there.
- Stable each-key for virtualized rows: use track identity
(`<namespace>-<id>-<number>`) instead of `${visibleTrackRange.start
+ loopIdx}-${track.id}`. The old key mixed scroll state into the
identity, so a re-render that bumped trackListScrollTop reused row
instances against new data — rows showed mismatched track / # /
album combinations until the next paint.
cc
- Reduce table-header left padding to 4px and switch the # column to
text-align: left so track numbers line up with the bare quality badge
in the toolbar above. Track rows in the album-detail context get the
same 4px left padding via a :global override on .track-list and the
track-number column switches to flex-start so the row stays aligned.
- Match the select-all checkbox width to the track-row checkbox (24px)
and shrink the checkbox itself to 15x15 so the header checkbox lands
on the same x as the per-row ones.
- Always render the select-all and track-row checkbox containers and
animate width + opacity in/out (180ms ease) so toggling select mode
no longer pops the column into existence.
- Move the AlbumMenu (...) trigger to the end of the action row so the
bulk-action triggers (CassetteTape, multi-select) sit before the
overflow menu — same convention as other detail views.
cc
Album detail UX overhaul:
- Artwork-derived gradient backdrop on the album header (opt-out toggle in
Settings > Appearance > Album header gradient).
- Bare mode for QualityBadgeStatic so quality reads inline without a
pill container.
- Shrink label/awards sidebar by ~20%.
- Inline tracks count and duration with the metadata row, format duration
as Xh Ym Zs.
- Plumb album description with sanitized HTML and click-to-expand.
- Tracklist toolbar with track search and inline quality badge.
- Featured artists and explicit (parental warning) badge in the artist
line.
- Reactive divider between header and tracklist (visible only when the
gradient is off), reactive action button colors, metadata centering
when there is no description.
cc
Inline UX iteration for the album detail header and tracklist:
- Tracklist toolbar above the table: bare quality badge on the left,
borderless search input on the right (filters tracks by title or
artist), with min-height alignment so badge and input share a baseline.
- Artist line renders the explicit badge (when parental warning) before
the main artist and appends featured artists separated by a dot, each
clickable to its discography. Featured names use a quieter weight and
size to keep the main artist primary.
- Reactive divider between header and tracklist: visible only when the
artwork gradient is off; when on, the gradient itself provides the
demarcation.
- Symmetric vertical breathing room around the toolbar (20px top to
optically compensate for the 40px-tall table-header below) and the
same offset on the album sidebar so the LABEL section aligns with the
toolbar row.
- Action buttons in the header (play/shuffle/heart/etc.) react to the
gradient backdrop with white-tinted strokes and a brighter hover.
- Metadata column vertically centers against the artwork when the
album has no description.
i18n keys added in all five locales: tracklist.searchPlaceholder,
tracklist.noMatches.
cc
Add Album.parental_warning and Album.artists (with the new AlbumArtist
struct) to qbz-models so the /album/get response surfaces both fields.
Mirror them in the QobuzAlbum / AlbumDetail TypeScript types and
populate featuredArtists (filtered to non-main contributors) and
parentalWarning in convertQobuzAlbum. Wire onFeaturedArtistClick from
the route so featured artist names route to their discography.
cc
Pass Qobuz album.description through adapter and AlbumDetail prop, then
render below the metadata row with a 2-line clamp and a Read more / Show
less toggle. Description HTML (Qobuz CMS markup with paragraph and
emphasis tags) is sanitized through the existing allowlist before being
inserted as HTML so the formatting renders cleanly without exposing
data-* attributes.
cc
Folds the standalone .album-quality and .album-stats lines into the
existing date/label/genre row, since the quality string moves to the
tracklist toolbar in a follow-up. Plumbs durationSeconds through
AlbumDetail and the Qobuz adapter, plus bitDepth/samplingRate so the
inline QualityBadgeStatic can render the same tier label as Mixtape and
Collection rows. Total duration renders as Xh Ym Zs (no leading zeros).
cc
The right-side sidebar was carrying too much weight versus the tracklist.
Drops the column from 280px to 224px and proportionally reduces avatar,
icon, and font sizes so the label/awards stack reads as supporting
metadata rather than competing with the track table.
cc
When bare={true}, the badge drops its background, border, padding,
min-width, and fixed height so it can render inline above a tracklist
toolbar without visually competing with the table header.
cc
Extracts the dominant color from album artwork (via node-vibrant) and
paints it as a soft top-down gradient behind the album header, with an
appearance toggle to opt out. Adds the .has-art-bg state class plus
contrast-lifted overrides for back button, info text, and artist link.
cc
Local Library follow-ups:
- fix tracks tab empty on initial load (active tab loader trigger)
- relocate edit-tabs button into folder management toolbar with sort icon
- give Albums tab the full Folders action bar (group/filter/sort/view/select)
- include Plex albums in metadata-grouped Albums view
- show Albums in Edit Library Tabs modal
- declare standard line-clamp alongside -webkit-line-clamp
cc
svelte-check warned about missing standard property next to the
-webkit-line-clamp prefix in 9 spots across 8 components. Add the
standard line-clamp declaration with the same value to clear the
warnings without changing rendering.
cc
The modal sanitizer filtered tab_order against availableTabs, which
omitted 'albums', so the metadata-grouped Albums tab never appeared
in the order/visibility editor. Add it to both availableTabs and
DEFAULT_TAB_ORDER to match the backend default and LocalLibraryView.
cc
loadMetadataAlbums only queried the local SQLite library, so the new
Albums tab was missing all Plex-cached content while the Folders tab
showed it. Mirror the merge pattern from loadLibraryData: fetch Plex
cached albums in parallel and concatenate after mapping.
cc
Albums was a stripped-down VirtualizedAlbumList over metadataAlbums with
no controls. Folders had the full toolbar (group dropdown, quality/format
filter, sort, view toggle, multi-select with bulk actions, alpha index).
The user wants Albums to be 1:1 with Folders, no reinvention.
Implementation:
- Extracted the album-controls block into a Svelte 5 snippet
`albumControls(alphaGroups)` declared at the top of the library-view
template. The snippet body references the existing shared state
(group/filter/sort/view/select); only `alphaGroups` differs per source.
- Extracted the body of `filteredAndGroupedAlbums` into a helper
`buildFilteredAndGroupedAlbums(source)` and added a parallel
`filteredAndGroupedMetadataAlbums` derived for the Albums tab.
- Generalized album-selection state: `currentAlbumsSource` switches
between `albums` and `metadataAlbums` based on activeTab; Select All
and the keyboard shortcut now operate on the active list. Bulk action
handlers look up album IDs across both lists (the two ID spaces don't
overlap) so playlists/queues/mixtapes work from either tab.
- Moved the album BulkActionBar out of the Folders branch so it renders
once for either tab.
- handleTabChange clears in-flight album selection when crossing tabs.
- metadataAlbums state declarations moved up to live next to `albums`
because the new selection derivations reference them earlier in scope.
cc
The gear icon mounted next to the tab buttons inside .jump-links was
visually noisy and overloaded a UX area meant for navigation. Moved it
into the folder-actions toolbar of the Library Settings panel, alongside
the existing folder management icon-btns (FolderPlus, PenLine, Trash2),
where ordering controls sit naturally. Swapped the icon from Settings
(generic) to ListOrdered (semantically a list-ordering action).
Removed the orphaned .jump-link.tab-edit-btn CSS rule.
cc
The visible-tabs rework moved tab order behind LibraryPreferences, so the
initial activeTab can be any of folders/albums/artists/tracks. Mount only
called loadLibraryData (folders + stats), leaving Tracks/Artists/Albums
empty until the user manually clicked a different tab and back.
Extracted the lazy-load switch from handleTabChange into
ensureActiveTabDataLoaded() and call it from both handleTabChange and
onMount once preferences (and therefore activeTab) have settled. Folders
needs no branch because loadLibraryData populates it.
cc
Includes Phase 2 (rename Albums to Folders + per-user tab order
preferences) and Phase 3 (new metadata-grouped Albums view + V2
commands + frontend wiring) of the Local Library design.
cc
Wires the new Albums tab in LocalLibraryView via the metadata-grouped
V2 commands. Default tab order in LibraryPreferences now includes
'albums' between 'folders' and 'artists'; existing users get the new
slot via sanitizeLibraryPreferences's backfill of missing default tabs.
LocalAlbum interface gains the optional source_folders field exposed by
v2_library_get_albums_metadata. fetchAlbumTracks branches on the album
record's source_folders presence: metadata rows fetch via
v2_library_get_album_tracks_metadata, folder rows keep using
v2_library_get_album_tracks. No separate viewMode plumbing is needed
because the discriminator travels on the row itself.
The Albums tab renders through the same VirtualizedAlbumList component
as Folders (single ungrouped group, no controls in this initial pass),
loaded lazily on first visit via loadMetadataAlbums. Cache invalidates
on every loadLibraryData, post-scan, and library-clear so a re-scan
surfaces fresh metadata grouping.
cc
v2_library_get_albums_metadata and v2_library_get_album_tracks_metadata
mirror the existing folder-grouped commands but call the new
get_albums_metadata_grouped and get_album_tracks_metadata. Frontend
wiring lands in the next commit.
cc
New album_grouping module exports the SQL CASE-WHEN expression for the
metadata group key shared by both new queries. The expression has three
priorities: (1) usable album tag => album|artist key, (2) fallback to
existing folder-based album_group_key, (3) orphan bucket
'__unknown_album__'.
get_albums_metadata_grouped runs the grouped query and returns
LocalAlbum rows with directory_path empty and source_folders populated
with the comma-separated list of contributing folders. Pinned 'Unknown
Album' to the bottom via ORDER BY.
get_album_tracks_metadata fetches all tracks belonging to a metadata
group, ordered by disc/track number.
Adds idx_local_tracks_album_lookup index on (album, album_artist,
artist) to keep the GROUP BY index-backed at 16k+ track libraries.
LocalAlbum gains an optional source_folders field (None for folder-
grouped rows; Some(comma-separated) for metadata-grouped rows). The
existing folder-grouped query continues to populate it as None so no
downstream consumer breaks.
Hidden filter (album_settings.hidden) does not apply to metadata-
grouped rows in this iteration; revisiting requires extending
album_settings to support metadata keys.
cc
Frontend half of Phase 2: rename the existing 'Albums' tab (which has
always shown folder-grouped data) to 'Folders' so the label matches
the behavior. NorinB filed issue #380 because of this discrepancy.
Wires LocalLibraryView to the new per-user library_preferences backend
(landed in the previous commit). LibraryEditModal is a clone of
FavoritesEditModal trimmed to the Library prefs scope (tab_order +
hidden_tabs only).
Tabs render in the user-defined order, hidden tabs are skipped. Active
tab on session start is the first visible tab. Default order matches
the current tab list with 'folders' in place of the old 'albums';
Phase 3 will add the new metadata-grouped 'albums' to the default.
i18n updated in 5 locales: new library.folders key + library.editTabs
block. Existing 'Library Folders' settings header moved to the new
library.folderListTitle key to free up library.folders for the tab
label.
cc
Mirrors the FavoritesPreferences pattern so the Local Library can have
a user-configurable tab order and hidden-tabs list, persisted per user
in library_preferences.db. Adds the LibraryPreferences struct, the
single-row SQLite table, the LibraryPreferencesState lifecycle, and
the V2 get/save commands.
Default order matches the current LocalLibraryView tab list with the
renamed 'folders' tab in place of the old 'albums' tab. Phase 3 will
add 'albums' (the new metadata-grouped view) to this default.
Frontend wiring lands in the next commit on this branch.
cc
A code review of the previous commit surfaced a second scan loop in
legacy_compat.rs that had the same destructive update_album_group_artwork
pattern. Apply the same fix: per-folder cache + drop the destructive
call. Same intent and shape as the main loop fix.
The deprecated marker also surfaced three callers that are NOT bugs
and stay as-is, with #[allow(deprecated)] + a comment to make the
intent explicit:
- CUE ingestion: a CUE sheet is one physical album, so a single cover
for all CUE tracks is semantically correct.
- v2_library_set_album_artwork: user-initiated set-cover-for-album, by
design propagates to all tracks.
- Discogs backfill: only fires for albums with no existing artwork, so
there is nothing per-track to destroy.
cc
The scan loop called update_album_group_artwork after every successful
track insert, which propagated one track's artwork to every track in
the same group. Tracks with unique embedded covers ended up sharing
whichever cover was processed last. Drop the call; per-track artwork
is resolved correctly by extract_artwork (embedded) + find_folder_artwork
(cover.jpg fallback) before insert_track.
Replace the propagation's caching effect with an in-memory per-folder
HashMap that memoizes find_folder_artwork for the duration of the scan
run, avoiding repeated folder walks for tracks without embedded art.
Mark update_album_group_artwork #[deprecated] so no future caller
reintroduces the bug. Keep it compilable.
Existing libraries already have collapsed artwork paths in the DB and
need a re-scan to recover per-track covers; called out in release notes.
cc
Fixes#387: seeks issued from the official Qobuz mobile app while qbz
acts as the active renderer were silently dropped — audio kept playing
from the original position while the controller's progress bar locked.
Two independent gates were swallowing the command between the
WebSocket and the audio thread; either alone is enough to break the
flow, so both move together.
1. Upstream filter (crates/qconnect-app/src/app.rs)
The is_echo guard treated every SET_STATE with playing_state=None
AND current_track=None as a pure server echo and skipped emitting
RendererCommandApplied. iOS sends a real seek as exactly that
shape (only current_position_ms set, the rest unchanged), so the
command never reached the bridge handler.
Add current_position_ms.is_none() to the guard. Pure echoes still
match (server echoes carry only next_track); position-only seeks
pass through.
2. Downstream gate (src-tauri/src/qconnect/corebridge.rs)
The seek branch was conditioned on peer_renderer_active — i.e. it
only acted when qbz was the controller observing a peer renderer,
never when qbz was the renderer itself. Removed; the existing
is_echo_reset and abs_diff > 2 checks already filter the redundant-
seek case the gate was originally added to defend against (147bcbd7).
Drop the now-unused is_peer_renderer_active import.
Verified manually: seeks from iPhone (renderer_id=3) targeting qbz
(renderer_id=2) at ~1/4 and ~5/6 of a 150s track now translate to
local audio jumps to the correct positions.
cc
The previous adaptive scheme dropped to 5s when idle and 1s when
paused-with-track. PLAYBACK_STATE_WAKEUP catches synchronous V2
mutations (pause/resume/stop/seek) instantly, but play_data() is
asynchronous: it queues an AudioCommand::Play over a channel and
returns. The audio thread sets is_playing/start_playback_timer only
after decoder + device init (500-2000ms), so the wakeup fires too
early — the loop reads stale state, decides 'idle' or 'paused', and
sleeps for the full long branch before catching the actual transition.
current_position() is wall-clock based (now - playback_start_millis),
so by the time the next idle tick arrives, position is already past
second 1 and the seekbar appears to jump straight to 2+.
Polling uniformly at 250ms catches the async transition within one
cadence and keeps the seekbar showing 0:00 -> 0:01 -> 0:02 from the
start. Cost is negligible (~10 atomic reads x 4 Hz), and should_emit
filters every no-change iteration so the frontend wire is unaffected.
cc
The background task in lib.rs that emits playback:state to the frontend
sleeps adaptively: 250ms while playing, 1s when paused-with-track, and
5s when idle. The 5s idle branch leaves the seekbar blank for several
seconds on cold start because the loop only reads new state — and only
then emits — after the sleep ends, even though audio is already playing.
Add a tokio::sync::Notify (PLAYBACK_STATE_WAKEUP) shared via the
existing commands_v2::helpers module. Race the sleep against
PLAYBACK_STATE_WAKEUP.notified() with tokio::select. Each V2 command
that mutates playback state (play_track, pause, resume, stop, seek)
calls notify_one after the underlying bridge call succeeds, so the
loop wakes within milliseconds and emits the new state immediately.
notify_one stores up to one permit when no waiter is parked, so a
notification fired between iterations is delivered on the next select.
Make commands_v2::helpers a pub(crate) module so the static is
reachable from lib.rs and the V2 command modules.
cc
Adds a pencil icon next to the usage bar in the manager's stats header.
Click opens a Modal (shared Modal.svelte) with a number input bound to
the current limit in GB plus Save / Cancel buttons. Validates >= 5 GB
client-side and surfaces a toast on success or error. Save calls
v2_set_offline_cache_limit which now persists across restarts. New
i18n keys land in all five locales (en/es/de/fr/pt).
cc
Pre-flight check_cache_limit reads the current total_size_bytes and
returns an informative error if the cache is already at or over the
configured limit. Wired into v2_cache_track_for_offline (single) and
v2_cache_tracks_batch_for_offline (batch). Re-download paths are not
gated since they target tracks already counted toward the total. The
check intentionally does not predict the new track's size; preventing
cache from growing further once at the limit is enough for v1.
cc
Adds cache_limit_bytes column to offline_settings (idempotent ALTER
TABLE) plus get/set methods on the OfflineState store. v2_set_offline_cache_limit
now writes the value to disk in addition to the in-memory cache_state.
At session activation, OfflineCacheState::apply_persisted_limit reads
the stored value and seeds the in-memory limit so the user's chosen cap
survives app restarts. Legacy installs with NULL persisted value keep
the in-memory 5 GB default seeded by init_at.
cc
Hi-res FLAC tracks at 24-bit/192 kHz routinely run 60-100 MB each, so a
2 GB cap held only ~25-30 album downloads before users had to clear
space. 5 GB is the new floor for the default; users can raise it from
the manager view and the value persists across restarts (next commits).
cc
The old label implied the entire album would be downloaded, but the
action only re-fetches tracks that were previously cached. Updated in
all 5 locales (en/es/de/fr/pt). The translation key stays
offlineManager.row.actions.redownloadAlbum.
cc
Replaces the static '⟳' / '…' status glyphs with the same SVG progress
ring used by DownloadButton. Album-row ring shows the average progress
across currently-downloading tracks; track-row ring tracks per-track
progressPercent. Queued state renders an indeterminate spinner
(75%-fill rotating). Same pattern, no new dependencies.
cc
Backend cache rows do not store an artwork URL, so on each fresh load we
hydrate per album_id via v2_get_album in batches of 8 with parallel
Promise.all and an in-memory cache. Failures per album are tolerated and
recorded as null so the row falls back to the placeholder. Re-rollups
triggered by progress events and remove operations re-apply the cache so
covers do not flicker on state updates.
cc
Auto-expand every album's track list on each rollup so the user sees
per-track status (size, badges, action menus) without needing to click
each row open. The set is sticky for the session — collapsing an album
manually persists until the next loadAll, and a fresh load re-expands.
cc
v2_check_albums_fully_cached_batch returns a HashMap<String, bool> which
serializes as a JSON object, not an array of tuples. The previous typing
made new Map(result) silently throw with object-is-not-iterable, which
aborted loadAll before buildRollup ran and forced the empty state on
every load. Use Record<string, boolean> + Object.entries instead, and
log loadAll errors so future shape mismatches do not fail silently.
cc
Repoints the handler from v2_open_offline_cache_folder to navigateTo('offline-manager').
The encrypted CMAF bundle layout makes the folder content meaningless to
users; the new in-app manager is the correct affordance for free-up-space
and per-album/per-track operations.
cc
Wires the new OfflineCacheManagerView into +page.svelte's view switch with
onBack returning to settings, onGoToAlbum delegating to handleAlbumClick,
and onGoToFavorites returning to the user's last favorites tab. Adds
'offline-manager' to the ViewType union so the new branch type-checks.
Excludes 'offline-manager' from last_view persistence by routing it through
getSessionFallbackView('offline-manager') -> 'settings' (its entry point),
guarding against Phantom CSS Error Cause 3 on rehydration when the cache DB
is not yet ready at first paint.
cc
Album rows show cover placeholder, title/artist, cached ratio, dominant
quality, size, and status badge. Menu actions: re-download album, retry
failed (visible only when applicable), remove from offline (with ask()
confirm), go to album. Expanded rows show per-track actions
(re-download, remove). Singles pseudo-album omits redownload-album and
go-to-album; remove iterates per-track.
cc
Rail groups artists by leading letter with sticky headers. Horizontal
alphabet bar above scrolls the rail to the chosen letter. Inactive
letters dimmed.
cc
Header + stats section with usage bar, sort dropdown, and Show only failed
toggle. Loading and empty states are placeholders for the next tasks
(left rail in P6.2, right pane in P6.3, empty CTA in P6.4).
cc
Adds the runtime API: loadAll, subscribeToProgress, removeAlbum,
redownloadAlbum, redownloadTrack, removeTrack, selectArtist,
toggleExpand, setSort, setShowOnlyFailed. Reuses buildRollup on every
mutation so derived ArtistGroup state stays consistent.
Subscribes to four downloader events: caching_started, caching_progress,
caching_processed (the canonical Ready signal), and caching_failed. The
intermediate caching_completed event is intentionally ignored since
caching_processed already covers post-processing completion.
Renamed the file to offlineCacheManagerStore.svelte.ts because Svelte 5
runes ($state, etc.) are only legal inside .svelte / .svelte.ts /
.svelte.js modules. Updated the vitest import in the sibling .test.ts
to match the new path.
cc
Pure function maps flat CachedTrackInfo[] into ArtistGroup[] with album
sub-groups, status rollup, quality dominance, and Singles bucket for
albumId-less tracks. Covered by vitest with 7 cases.
cc