Wire isTrackRemovedFromQobuz into TrackRow consumers (VirtualizedTrackList,
AlbumDetail, DynamicSuggest, TopQ, WeeklySuggest). Tracks marked unstreamable
by the API or hit by a TrackUnavailable error this session now render dimmed,
non-draggable, non-clickable, with the i18n tooltip player.trackUnavailable.
Reactivity for the {@const} reads uses a $state counter bumped from the
unavailable-tracks store subscription so the dim state updates live without
forcing the whole list to re-render.
cc
Hotfix release. Reverts titlebar-mode-selector and PR #406 (broken
window chrome on Linux); preserves PR #407 (sticky home header) and
adds PR #408 (macOS native icon and tray variants by @Vudgekek).
cc
v1.2.12 hotfix: revert the titlebar-mode-selector merge and PR #406
which together caused all titlebar modes to render incorrectly in the
production binary (empty top space, gap above now-playing bar). The
v1.2.10 model (use_system_titlebar + hide_title_bar booleans) is
restored; Plasma mode is dropped pending a less brittle redesign.
PR #407 (sticky home header fix) is preserved.
Reverts merge f7623154 which built macOS support on top of the variant
rendering infrastructure introduced by titlebar-mode-selector. With the
variant system gone (previous revert), PR #406's changes (class:macos,
stripped strip with traffic-light reservation, macOS-aware drag bands)
reference dead architecture.
Most of PR #406 was already removed by the previous revert via file
checkouts; this commit cleans up the residual macOS-specific cosmetics
in Sidebar.svelte and +page.svelte (padding-top 36→32, drag-region
height 32→28, macos main-content padding restored to generic rule).
PR #407 (sticky home header fix, ac627d43) is preserved — independent
from titlebar work, only touches HomeView.svelte.
cc
Reverts merge 1be46f1a which introduced the 4-mode titlebar enum
(qbz/system/plasma/hidden), variant-aware rendering (full/stripped),
GDK_BACKEND=x11 coercion for Plasma, and per-mode mounting in +page.
The "stripped strip mounted alongside Plasma SSD" architecture caused
all titlebar modes to render incorrectly in the production binary —
empty space at top, separation gap above the now-playing bar, controls
visible without a backing strip. Reverting restores v1.2.10 behavior:
two booleans (use_system_titlebar + hide_title_bar), no variants, no
GDK coercion. Plasma support is dropped pending a less brittle redesign.
Conflicts in SettingsView.svelte resolved by taking the pre-merge side
for the four titlebar UI blocks; non-titlebar additions in this cycle
(Discord RPC settings, reserve-DAC, offline cache nav) preserved.
cc
Update qbzVersion + srcHash + npmHash for the v1.2.11 tag (commit
cc98b7f7). Hashes computed via 'nix-prefetch-url --unpack' and
'prefetch-npm-deps package-lock.json' against the tagged tree.
cc
PR #406 was the macOS titlebar unification (mentioned separately in the
Title bar section); the sticky home header pop-in fix is PR #407. Both
from @afonsojramos in the same merge round.
cc
Discord RPC now reaches Discord-Flatpak's IPC socket without making the
integration opt-out at the sandbox layer. Bridge symlinks are created
lazily on first connect after the user enables the toggle; users who
never enable Discord RPC see no sandbox changes.
* prepare_flatpak_discord_socket_links in discord_rpc.rs runs from the
lazy connect path, gated on FLATPAK_ID env var
* Manifest gains xdg-run/app/com.discordapp.Discord:create filesystem
permission, kept alongside the existing static path
Discord-Flatpak writes its IPC socket to
\$XDG_RUNTIME_DIR/app/com.discordapp.Discord/discord-ipc-N rather than
the \$XDG_RUNTIME_DIR/discord-ipc-N path the discord-rich-presence crate
searches by default. The naive way to bridge that gap — a startup
wrapper that unconditionally symlinks every iteration before the binary
launches — works, but it pulls the integration off-script: every other
optional integration in QBZ (Last.fm, ListenBrainz, MusicBrainz, Plex)
is opt-in, with zero telemetry and zero filesystem reach until the user
flips the toggle. A startup wrapper would silently turn Discord RPC
into opt-out at the sandbox layer; the symlinks would exist for users
who never touched the integrations panel.
Move the bridge into the lazy connect path instead. The first time the
user actually connects — i.e. the first track plays after they enabled
the toggle — prepare_flatpak_discord_socket_links runs:
* No-op outside Flatpak (no FLATPAK_ID env var)
* No-op when \$XDG_RUNTIME_DIR/discord-ipc-N already exists (native
Discord, or a previous lazy run in this Flatpak instance)
* No-op when the Discord-Flatpak source path doesn't exist (Discord
not running)
* Otherwise, symlinks 0..9 from \$XDG_RUNTIME_DIR/discord-ipc-N to
\$XDG_RUNTIME_DIR/app/com.discordapp.Discord/discord-ipc-N
Symlinks die with the Flatpak instance — \$XDG_RUNTIME_DIR is per-
instance, so there's no cross-session pollution. Users who never enable
Discord RPC keep the same sandbox surface as before.
Manifest: keep the static xdg-run/discord-ipc-0 permission for native
Discord, add xdg-run/app/com.discordapp.Discord:create so the lazy
symlink path is allowed when the user opts in. The two coexist; the
crate's default search path covers Discord-native, the symlinks cover
Discord-Flatpak.
cc
Drains the wiki "23 warnings" baseline back to zero so future regressions
stand out:
* Drop 16 unprefixed \`app-region\` CSS lines across TitleBar, TitleBarNav,
ImmersiveHeader, ImmersivePlayer, MiniPlayerFooter and MiniPlayerWindowControls.
Tauri's webview is WebKit / Chromium on every platform we ship to, so
\`-webkit-app-region\` already covers the chrome-drag region; the
unprefixed property only existed to keep stylelint quiet but the CSS
spec doesn't define it and svelte-check warns on every site.
* Remove 2 empty rulesets in AlbumCreditsModal and NowPlayingBar (leftover
comment-only blocks from earlier refactors).
* Pair \`-moz-appearance: textfield\` with the standard \`appearance: textfield\`
in LocalLibraryTagEditorModal so the rule no longer trips the
property-companion warning.
* Delete the unused \`.quality-text\` selector in NowPlayingBar.backup.svelte
(file is a frozen snapshot of an older NPB iteration; the live NPB
doesn't use that class).
* Annotate Tooltip.svelte and immersive/panels/VinylPanel.svelte with
\`svelte-ignore a11y_no_static_element_interactions\` plus a comment
explaining the context — both are decorative wrappers (a tooltip hit
area; a 3D tilt effect on artwork) where adding an ARIA role would
misrepresent the semantics.
* Add \`@types/node\` as a devDependency (^22) so the tsconfig resolution
for the SvelteKit-extended config stops complaining about the missing
type definition.
cc
Open Folder — play music ad-hoc without adding to library.
* Backend: ephemeral_library module (in-memory HashMap, synthetic
high-positive ids, CUE support, format whitelist, artwork cache,
gapless prefetch).
* Frontend: Open Folder button in Folders > Tree toolbar; pane mirrors
album-detail layout with cover-art per group + disc sections.
* Persistence: folder path survives restart (rehydrated on mount); the
ephemeral data itself is rebuilt fresh.
* Lifecycle cleanup: queue + NowPlayingBar wiped on broom click /
navigation away / inaccessible folder.
* Playback: gapless across virtual track boundaries (CUE-FLAC) with
position offset translation so the seekbar reads in virtual time.
* Action gating: favorite + playlist + track info disabled (not removed)
for ephemeral tracks across NowPlayingBar, QueuePanel, immersive.
* Folder-tree sidebar polish: search right-aligned, default 290px width.
Library-bound actions (favorite, add-to-playlist, track info) require a
DB row to operate; ephemeral tracks live only in memory and would 404
on use. Surface them disabled rather than removed so the affordance
stays discoverable in the chrome — users still see the icon, the
tooltip explains why it's inert.
Surfaces wired
- NowPlayingBar: + and ♥ disabled when active track id >= 2^48.
- QueuePanel: now-playing ♥ + per-track Add to Playlist + Track Info
in both queue and history menus disabled per-row by id range.
- ImmersiveControls + PlayerControlsCompact: ♥ disabled.
- ImmersivePlayer: passthrough prop to PlayerControlsCompact.
- MiniPlayerFooter / MiniPlayerWindowControls: no metadata actions in
these surfaces, nothing to gate.
Detection is by id range (>= EPHEMERAL_ID_FLOOR), not by source string.
The setQueueForLocalTracks mapping rewrites `source: 'ephemeral'` to
`'local'` before the track reaches the queue, so a string-equality
check would never fire — id range matches the rest of the ephemeral
codebase (gapless carve-out, CUE boundary, seek offset, queue/now-
playing wipe).
Tooltip on the disabled state uses the new
`actions.unavailableForEphemeral` key, translated for en, es, de, fr, pt.
cc
Three coupled visual bugs that surfaced once the queue advance and
gapless wiring started landing virtual track changes correctly.
Seekbar (case A: manual click / next button)
- PlayingTrack gets cueStartSecs / cueEndSecs fields and a
patchCurrentTrackCueInfo() setter on playerStore. handlePlaybackEvent
subtracts cueStartSecs from event.position when computing the
displayed currentTime, so a virtual track that lives at e.g. offset
7:26 inside its FLAC reads 0:00 at frame zero instead of 7:26 / 0:00.
- LocalLibraryView keeps an $effect on activeTrackId that looks up the
matching ephemeral track and patches the cue info into playerStore
every time the active track flips. Non-CUE / non-ephemeral tracks
fall through with null/null which clears any prior boundaries.
Seekbar (case B: CUE boundary auto-advance)
- handlePlaybackEvent gains an "underlying CUE" branch: when event.track_id
is in the ephemeral range AND currentTrack is in the ephemeral range
AND currentTrack has cueStartSecs, the strict track_id match check is
bypassed and the same offset translation runs. This keeps the seekbar
ticking in virtual-track time after the boundary watcher swaps the
displayed metadata while the audio buffer is unchanged. EPHEMERAL_ID_FLOOR
is promoted to a module constant so both branches share it.
Album art (manual next button)
- queueArtwork.resolveQueueTrackArtwork was double-converting Tauri
asset:// URLs through convertFileSrc, producing a 403 path
(asset://localhost/asset%3A%2F%2F...). Add an asset: passthrough
alongside http(s) and file:// so URLs already resolved upstream
(LocalLibraryView's queue setter pre-converts via getArtworkUrl)
reach the NowPlayingBar intact.
Quality badge ("audio downgrade")
- Boundary advance's setCurrentTrack call was passing LocalTrack.sample_rate
(Hz) into PlayingTrack.samplingRate (kHz). The badge formatted as
"FLAC 16/44100" instead of "FLAC 16/44.1", reading as a quality drop
vs. the album list. Divide by 1000 to match the rest of the
PlayingTrack contract.
cc
A consolidated UI commit covering the LocalLibrary pane work from this
session. Backend additions land in earlier commits; this one is the
frontend surface plus the i18n strings.
Folder-tree sidebar polish (Folders > Tree)
- Search input pushed to the right edge via margin-left:auto so the
action icons stay anchored on the left.
- FOLDER_TREE_SIDEBAR_DEFAULT_WIDTH and *_MIN both bumped to 290px (was
302/200) — matches the user-tuned width and sets a coherent floor for
the resize handle.
Ephemeral pane render
- Group tracks by album_group_key and render an album-style block per
group: 96px cover, title/artist/year/quality/track-count, per-album
Play button. Discs render as section headers when an album has more
than one. CUE-derived virtual tracks slot into the same structure
(they share the underlying FLAC's group key).
- Single-album sessions hide the per-album play button — it's redundant
with the folder-level "Play all" header. Multi-album sessions surface
it so each album has its own affordance.
- Loading spinner replaces the empty-pane state during scan; on slow
NAS folders the wait used to be silent.
- Close button changed to the BrushCleaning icon ("Clear session" /
"Limpiar sesion") — the X read as "close window" rather than "wipe
this transient state". Strings updated in en, es, de, fr, pt.
Ephemeral session lifecycle
- Folder path persists to user storage; rehydrateEphemeralFolder reopens
it on mount as long as the directory still exists. A `rehydrating`
flag suppresses the auto-close $effect during the rehydration window
so a delayed result doesn't get clobbered by a concurrent navigation.
- wipeEphemeralPlaybackArtifacts clears the queue + now-playing slot
when the active or pinned track is ephemeral. Detection consults both
activeTrackId AND playerStore.currentTrack — sometimes the track is
visually pinned without playback being active (e.g. after session
restore). Backend stop+clear doesn't reset current_track_id at the
player level, so the frontend explicitly nullifies currentTrack to
flush the NowPlayingBar chrome.
- Closing/replacing an ephemeral session triggers the wipe; navigation
to a tree folder or album also triggers it via the watch-effect.
Ephemeral playback flow
- Queue scope is now the album the played track belongs to, not the
whole folder. Folder-level "Play all" still queues everything in
album-then-disc-then-track order; track-row clicks queue just the
album. Per-album play button matches.
- Loading toast (buffering type) shows immediately on play to cover the
multi-second file read off NAS. Dismissed when v2_library_play_track
resolves.
- CUE virtual-track boundary advance: a raw playback:state listener
watches absolute FLAC position and, when the displayed CUE track's
cue_end_secs is crossed, calls v2_next_track + setCurrentTrack(next)
+ syncQueueState. Audio is unchanged (continuous decode of the same
FLAC); only the chrome flips. Seekbar resets to 0:00 because the
player's track_id stays pinned to the first virtual track — known
limitation queued for a follow-up that virtualises position display
via cue_start_secs offsets.
cc
Two cross-cutting plumbing changes the ephemeral folder pane needs to
behave correctly without touching the rest of the playback paths.
playerStore: gapless transition detection used to require event.is_playing
to be true at the moment the track_id changes. The ephemeral file-data
swap (one buffer of bytes replacing another) briefly reports is_playing
false during the engine restart, and that transient frame arrives BEFORE
the player can flip the flag back. Without a carve-out the event was
mis-classified as an external track change, the queue's current_index
hadn't advanced (it's a separate state machine), and the UI froze on the
previous track. Streaming playback paths don't see this race because
their buffer transition keeps is_playing pinned. Carve-out is scoped to
event.track_id >= EPHEMERAL_ID_FLOOR so Qobuz/Plex/library are untouched.
routes/+page.svelte: session save now strips ephemeral tracks from the
queue snapshot and clears current_index if it pointed at one. Ephemeral
ids aren't stable across processes (the cache rebuilds from scratch on
each launch), and even if they were, the playback path 404s if session
restore fires before the folder rehydrates. The folder-path persistence
already brings the pane back; the queue can come back via re-clicking.
Also: gapless transition handler logs the advance/expected ids and the
match flag, plus runs a defensive syncQueueState on success — that's
belt-and-suspenders for paths where the queue's view of "current" lags
the player's gapless transition by a tick (e.g. ephemeral, where the
queue advance and the player swap are independent state machines).
cc
Backend additions for the ephemeral folder feature, all gated behind the
existing track_id >= EPHEMERAL_ID_FLOOR discriminator so library/Qobuz
paths stay untouched.
CUE handling
- Iterate scan.cue_files alongside audio_files. Parse via CueParser,
resolve + canonicalize the referenced audio file, extract properties +
format via MetadataExtractor, expand into virtual tracks via
cue_to_tracks. The referenced audio file is added to a HashSet so the
audio_files loop skips it (otherwise we'd list both N CUE-derived
tracks and a single-row entry for the underlying FLAC).
- Whitelist of decoder-friendly extensions (flac/mp3/m4a/alac/wav/aiff/aif).
Symphonia can't decode APE or raw BIN — those used to ride through
silently and produce white noise on play; now they're skipped with a
warn log and counted in skipped_files.
- v2_library_play_track ephemeral branch now honours track.cue_start_secs
with a post-play_data seek, mirroring the regular local-file branch.
Artwork perf
- Cache extracted artwork by album_group_key. Most CUE-album folders
share one cover across every virtual track, so calling extract_artwork
+ generate_thumbnail 155 times for a 155-track album was wasted work
(the bottleneck behind the prior ~2-minute opens). The folder-level
cache is kept as a secondary fallback for find_folder_artwork lookups.
- Skip standalone APE files entirely from the ephemeral pane — Symphonia
can't decode them so showing them as a row that explodes on click is
worse than not showing them at all. Counted in skipped_files.
Gapless
- v2_play_next_gapless gets an ephemeral branch at the top: track ids
in the high range resolve through EphemeralLibraryState and feed the
player.play_next path directly. Without this, gapless prefetch always
reported "not in any cache" because the lookup keyed off DB rows.
- CUE-derived ephemeral tracks (cue_start_secs is Some) return false
from the gapless branch — they share an audio file, so a play_next
swap with no seek would start the next virtual track from the FILE's
beginning instead of the CUE offset.
cc
The original design used negative i64 ids to distinguish ephemeral
tracks from DB rows, but the queue and playback-context commands
serialize ids as Vec<u64> end-to-end (V2QueueTrack, v2_set_playback_context).
serde rejects negative numbers at the boundary, so setQueueForLocalTracks
and setPlaybackContext failed the moment the user clicked an ephemeral
track:
invalid value: integer `-1`, expected u64
Switch synthetic ids to a high-positive range (EPHEMERAL_ID_FLOOR =
1<<48) instead. Pros over the negative scheme:
* Valid u64 — passes through the queue/context commands untouched
* Below 2^53 so JS Number round-trips without precision loss
* Far above any realistic DB autoincrement id, so the discrimination
test (`track_id >= EPHEMERAL_ID_FLOOR`) stays unambiguous
Also fixes the snake_case mismatch on EphemeralFolderState — the Rust
struct serializes with its native field names (folder_path,
skipped_files), but the TypeScript type was declared in camelCase, so
ephemeralFolder.folderPath was undefined at render time.
cc
Adds an "Open Folder" button in the LocalLibrary > Folders tree toolbar
that lets the user point QBZ at any folder on disk and play it without
adding anything to local_tracks. Lets the app act as an ad-hoc media
player for downloaded albums, external drives, etc.
Backend:
- New ephemeral_library module: in-memory HashMap<i64, LocalTrack> keyed
by synthetic negative ids. Reuses LibraryScanner and MetadataExtractor
to walk the folder and build LocalTrack records.
- Three V2 commands: v2_ephemeral_open_folder (scan + populate, replaces
any prior session), v2_ephemeral_clear, v2_ephemeral_get_track.
- v2_library_play_track gets a small branch at the top: track_id < 0
resolves through the ephemeral state and goes straight to the regular
local-file playback path (read file, hand to player). DB-backed paths
(Qobuz cache, CUE start positions) are skipped — ephemeral tracks
can't be any of those.
Frontend:
- "Open Folder" button (FolderOpen icon) next to the collapse-all button
in the tree toolbar. Click opens the OS file picker, the result lands
in a new ephemeralFolder $state.
- Right pane gets a new branch above the existing album/folder views:
when ephemeralFolder is set, render a dedicated pane with header
(folder name, path, track count, Play All, Close) and a flat track
list with play-on-click rows.
- Lifecycle per spec: a session survives until (1) a different album or
tree folder is selected — handled via $effect that watches
selectedAlbum and selectedFolderPath, (2) the user clicks the Close
button, or (3) the backend reports the file/directory is gone (caught
in the play handler and folded into the same close path).
- Translations for en, es, de, fr, pt.
Notes:
- The audio_cache key is `track_id as u64`, which for negative ids wraps
to a large u64 value — unique and safe (DB ids are positive).
- Edit-metadata gating for ephemeral tracks is deferred: there is no
edit trigger from the new pane yet, so the sidecar/direct toggle
behaviour is unchanged. Will gate when an edit path lands.
cc
Sleep timer for the QueuePanel toolbar (issue #402).
* Frontend-only timer that hard-stops via cmdPause on expiry
* Presets 30 min / 1 / 2 / 3 / 5 hr plus Custom (1-1440 min)
* Green countdown beside the button when active; click to edit/cancel
* Smart-positioned popover (viewport-aware, recomputes on scroll/resize)
* Restrained type scale to keep visual hierarchy harmonized
* Translations for en, es, de, fr, pt
15-minute floor was arbitrary; the user wants the lower bound at 1 minute
for short audio-segment workflows. Constant change propagates to both the
store's clamp and the input's min attribute.
cc
Two issues from initial review:
1. The popover was anchored with position: absolute relative to the toolbar
wrap, so when the queue panel sat near the viewport edge or the user had
the window short, the popover overflowed and got clipped. Switched to
position: fixed with a viewport-aware placement helper that prefers
above the trigger, flips below when there isn't headroom, and shifts
horizontally to stay inside the viewport. Recomputes on resize and on
any ancestor scroll (capture-phase) so the popover stays anchored.
2. The Dropdown trigger was the visual peak of the stack — its default
14px label dwarfed the surrounding eyebrows and made the popover read
as inconsistent. Restructured the layout to a stacked eyebrow-above-
control rhythm with one restrained type scale (11px eyebrow / 13px
control / 12px action) and overrode the Dropdown trigger locally so
it matches the input field. The countdown stays slightly larger (15px)
as the deliberate focal point of the active state.
cc
Adds a frontend-only sleep timer button to the queue panel toolbar. When
the deadline expires the timer invokes cmdPause() — no fade, no DSP, just
a hard stop consistent with QBZ's bit-perfect posture.
UX:
- Clock icon button next to the infinite-play toggle in QueuePanel.
- Click opens a popover with presets (30 min, 1 hr, 2 hr, 3 hr, 5 hr) plus
a Custom option that reveals a 15-1440 minute number field.
- Once active, the button turns green and a small green countdown label
appears beside it (no background, no border) showing the remaining time.
- Clicking the button again opens an Edit/Cancel view; Edit pre-fills the
form with the current duration so the user can re-pick.
Behavior per spec:
- Frontend-only (single setTimeout fires the pause), no backend command.
- Timer does not persist across app restarts.
- Hard pause only; the app stays open so the user can resume where it
stopped.
Translations added for en, es, de, fr, pt.
cc
The tree-sidebar resize handle uses role="separator" with aria-orientation,
aria-valuenow/min/max and arrow-key handlers — the WAI-ARIA splitter
pattern. Svelte's a11y linter classifies role="separator" as
non-interactive and flags both tabindex="0" and the mousedown/keydown
listeners, even though both are correct for a resizer.
Suppress the two rules locally with svelte-ignore directives so the
console stops printing them on every dev reload. Behavior unchanged.
cc
Two cooperating changes that flatten the long sort tail in the Tracks tab:
1. search_with_filter now ORDER BYs (album, COALESCE(album_artist, artist),
disc_number, track_number, title) COLLATE NOCASE — matching the default
album-grouped browsing. SQLite handles 100K rows in well under 100ms;
the JS sort took several seconds for the same volume.
2. groupTracks replaces the chained localeCompare with decorate-sort-
undecorate using precomputed composite keys (lowercase strings with a
sentinel separator and zero-padded numeric segments). The Intl collator
instantiation per comparison was the bulk of the per-pass cost.
Both changes preserve the existing ordering semantics for the album, artist
and name modes; locale-specific accent ordering becomes lowercase ASCII
order, which is acceptable for library browsing.
Tier 3 of three.
cc
Replace the bulk Plex hydration that fired on every Tracks-tab open with a
viewport-driven path. VirtualizedTrackList now emits onVisibleTracksChange,
LocalLibraryView debounces those events (150ms) and hydrates only the rows
in the visible window — at most a few dozen calls instead of 16K+.
Hydration results land in a reactive plexQualityOverrides map (SvelteMap).
getQualityBadge consults the map for plex tracks, so badges update without
reassigning `tracks`. That removes the second full pass through groupTracks
that the old `tracks = [...localTracks, ...hydratedPlexTracks]` reassignment
used to trigger after every hydration completion.
loadTracks is now a single reactive write; the requestId still invalidates
in-flight viewport batches when the search query changes.
Tier 1b + Tier 2 of three.
cc
The unbounded Promise.all over every Plex candidate flooded the server's
per-host connection limit and held the JS event loop until the whole list
settled. With 16K Plex tracks the freeze ran into minutes.
Replace it with sequential batches of 5 (mirroring the existing pattern in
hydrateUnhydratedPlexTracks) and a 5s Promise.race timeout per call so a
slow/dead Plex host can't stall the whole pipeline.
Tier 1a of three; album-detail view uses the same helper so it benefits
even though its track scope is bounded.
cc
LocalLibrary Folders tab gains a Poweramp-style tree view (resolves#405):
two-column layout with a resizable tree rail, lazy-loaded children,
search-with-parent-preservation, recursive multi-select, compact album
detail and folder detail right panes, dedicated tree search, navigation
dropdowns in titlebar + sidebar, and many UX fixes layered in over
multiple iterations with the user.
cc
Replace the reinvented .artwork-context-menu-* classes with the
canonical .cover-context-* classes used by AlbumDetailView so the
menu visually matches the rest of the app. Menu still has only the
two simple options that apply to local library (View album art /
Edit metadata) - no custom-cover, browser, or save-as handlers, since
local-library artwork is a file path and cover changes belong to the
metadata editor. The ImageLightbox usage was already correct.
cc
Right-clicking the artwork in the compact album view now opens a
small context menu with two options: View album art (opens the
existing ImageLightbox component) and Edit metadata (the existing
edit-modal flow). Click outside or Escape closes the menu.
cc
Title bar and sidebar Local Library entries become dropdowns showing
Albums / Artists / Tracks / Folders in the user's configured order.
Clicking a tab navigates to the view and switches to that tab.
Two new shared stores back this:
- libraryPreferencesStore: tab_order + hidden_tabs, hydrated by
LocalLibraryView on mount and on save, plus a standalone fetch path
used by the navigation components when the view hasn't mounted yet.
- libraryTargetTabStore: a one-shot navigation target consumed by
LocalLibraryView either at initial-mount time (inside the existing
initialTabSet handshake in loadLibraryPreferences) or live via
a dedicated $effect when the view is already mounted.
The title bar uses a hover dropdown that mirrors the existing My QBZ
pattern; the sidebar uses a collapsible parent row with NavigationItem
sub-items, matching the My QBZ section's visual register. Both filter
to the user's visible tabs only and respect the saved tab_order.
cc
The page header now leads with the Local Library title alone. The
gradient drive square next to the title fought the rest of the chrome
without earning the visual weight, so it goes away. Markup and the
matching .header-icon CSS rule both removed; the .header flex container
is left intact for the title block.
cc
Right-clicking the artwork in the compact tree-mode album view now opens
the same album-edit modal as the pencil button in the page-level album
view. Plex source still requires the metadata-write opt-in, mirroring
the existing flow.
Implementation routes through a tree-specific helper that bridges the
modal's selectedAlbum / albumTracks dependencies to selectedAlbumForTree
/ treeAlbumTracks, gates the page-level album-detail takeover behind a
treeAlbumEditMode flag so the tree rail stays visible, and clears the
borrowed state through a cleanup effect once both album-edit and
tag-editor modals close.
cc
Compact album view (tree-mode right pane) gains a selection toggle,
per-track checkboxes, and Play Next / Add to Queue / Add to Playlist
bulk actions alongside the existing album-level Add to Mixtape button.
When tracks are selected the mixtape button bulk-adds those tracks
instead of the whole album. Track search aligns to the right of the
action row (margin-left: auto). Selection resets when the user
navigates to a different album in the tree, so it doesn't bleed
across albums. Local + Plex split mirrors the tracks-tab handler so
each path stays in its own backend namespace.
cc
The big pill "Play all" goes away. Header gains a circular primary
Play button (action-btn-circle.primary) that replaces the folder
glyph and triggers recursive playback. A new right-aligned search
filters subfolder cards by name. View-mode toggle switches between
the existing grid and a new compact list view of subfolders.
cc
Tree search drops its flex-grow and pins to ~140px width. Check and
collapse-all icons shrink from 16px to 14px (2pt) for a tighter
register that matches the row icon size. Toolbar padding tightens
slightly to compensate.
cc
The select-mode toggle drops its backgrounded style for a flat icon
that just changes color when active. New collapse-all button next to
it clears every expanded folder. Tree mode gains its own dedicated
search input alongside the toolbar buttons, decoupled from the
global album search. Folder rows lose the inline (N) track count for
a cleaner Plex/foobar register; the underlying count fetch stays in
place.
cc
The compact album view's Play All / Shuffle buttons drop the pill
treatment and adopt the standard app button style. A new track search
input filters the displayed list by title or artist; empty results
show a short message. New i18n keys added to all five locales.
cc
Flat/Tree switch moves inline with the tabs row (right-aligned, low
weight, only on Folders). Page-level search icon hides on the Folders
tab — flat mode has its own inline search; tree mode gains a dedicated
tree-search in a follow-up. Two-column divider extends up to almost
touch the jumpnav by relocating the toolbar inside the tree column.
cc
The horizontal scrollbar never appeared on .folder-tree-scroll despite
rows declaring width: max-content. Root cause was contain: strict on
the scroll wrapper, which includes size containment; WebKit (Tauri)
under-reported scrollWidth so the bottom scrollbar was suppressed even
when long folder names extended past the rail. Drop size containment
(keep layout + paint for perf) and add flex-shrink: 0 to .row-name so
the flex row doesn't collapse the name and force the row's max-content
width to actually take effect.
The resize handle gains a centered vertical pill (4px x 36px, 40%
opacity by default, accent on hover/drag) so the divider has a visible
affordance independent of cursor rendering quirks under Wayland or
XWayland. Hit area widened to 8px for easier grabbing. Drag/keyboard
behavior and a11y attributes are unchanged.
cc
Default width drops from 432px to 302px (~30% smaller). Long folder
names no longer ellipsize - the sidebar scrolls horizontally inside
itself when content exceeds the column width. Drag-resize and the
persisted width preference are unchanged: the user can still widen
to 40% of the content area, and that choice survives restart.
The resize handle stays anchored to the column's right edge because
it sits as a sibling of the scroll wrapper (.folder-tree-scroll),
not inside it - horizontal scroll inside the wrapper does not drag
the handle along.
cc
Tighter row padding (3px), smaller typography (~12.5px), 12px depth
indent, 14px icons. Removes the artwork thumbnail in favor of always
showing the folder icon. Track count moves inline as a muted suffix
(N) instead of a subtitle row, hidden when zero. Visual register
matches Plex/foobar2000 file-tree conventions.
cc
New LocalLibraryFolderAlbumView renders a stripped album detail
(artwork + metadata + play + track list) sized for the tree-mode
right pane. The full-page album-detail view is unchanged; tree mode
no longer triggers the page-level takeover when an album folder is
selected, so the tree rail stays visible while browsing.
Tracks load into a separate `treeAlbumTracks` state via $effect on
`selectedAlbumForTree` (raceless: commits only when the selection
still matches at resolve-time). Play / shuffle / track-click set the
playback context against the tree-selected album so the now-playing
source label remains accurate.
cc
Sidebar default widens from 240px to 432px. A 6px right-edge handle
resizes between 200px and 40% of the content area. Width persists
across restarts via v2_set_library_folders_tree_sidebar_width on
drag-end. ResizeObserver tracks content-area changes (window resize +
app sidebar toggle) and re-clamps the user width when the cap shrinks.
Keyboard parity: arrow keys nudge in 16px steps (64px with Shift),
Home/End jump to bounds. Aria semantics use role="separator" with
valuenow/min/max. Five-locale i18n for the handle label.
cc
New i64 field on LibraryPreferences for the tree-mode sidebar width
the user chooses via drag. Migration adds the column non-destructively;
NULL means "use default".
cc
The view used to hardcode `activeTab = 'tracks'` on mount, which on
large libraries (16K+ tracks) causes a startup freeze regardless of
the user's preferred ordering. Now `activeTab` initializes to the
first visible tab in the user's configured order. Existing reactivity
that follows tab visibility changes (e.g. when the user hides their
current tab) is preserved.
cc
Top-level scan-root rows now fetch their recursive descendant count
via v2_library_count_folder_tracks instead of showing a hardcoded 0
(closing the Task 7 trade-off). Cache invalidates when the exclude-
network filter flips. The tree itself loses its per-row play button:
navigation-only, with recursive playback available from the right-
pane detail view.
cc
Lightweight COUNT(*) helper for the tree-mode scan-root rows that
needed a recursive descendant count. Same network-folder filter as
the listing primitives.
cc
All tree-mode invokes route through shouldExcludeNetworkFolders() so
the tree rail mirrors the flat list and recursive-play boundary.
Closes the visible/play semantic mismatch flagged by Task 10.
cc
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