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
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
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
v2_remove_cached_album, v2_redownload_cached_album (with failed_only flag),
and v2_redownload_cached_track. Thin dispatchers over offline_cache::*
following V2 cleanroom rules.
cc
Pure logic, no Tauri state. remove_album_cached_tracks bundles the
SQLite delete with on-disk CMAF cleanup. select_redownload_targets
enforces the rule that re-download skips in-flight Downloading tracks
and optionally restricts to Failed only.
cc
The previous commit added a Pending variant to OfflineCacheStatus, but
the existing Queued variant already represents 'freshly inserted,
awaiting downloader pickup' (insert_track writes status='queued').
Reusing Queued keeps reset_track_for_redownload semantically aligned
with insert_track and preserves the in-flight count SQL that already
matches WHERE status='downloading' OR status='queued'.
cc
get_album_tracks returns rows of any status (needed for re-download
which targets ready+failed but skips downloading). reset_track_for_redownload
clears progress and error so the downloader can restart cleanly.
Also adds OfflineCacheStatus::Pending (serializes/parses as 'pending') so
reset_track_for_redownload has a target state distinct from Queued and
Downloading. The frontend type union (per plan) already includes 'pending'.
Factors out row_to_cached_track_info as a private free function so
get_track, get_all_tracks, and the new get_album_tracks share the same
15-column row decoder.
cc
The previous filter_map(.ok()) silently dropped per-row decode errors,
which would have caused the returned ids vec to be truncated while the
rows were still counted in the byte total and physically deleted. P2.2
and P2.3 will use the returned ids to clean filesystem artifacts; a
silent truncation would leak files. Promote row errors to the function
boundary instead.
cc
Single-transaction batch delete keyed by album_id. Returns the deleted
track_ids and the total bytes freed so callers can clean filesystem
artifacts and report freed space.
cc
Moves spawn_track_cache_download and try_cmaf_offline_download from
commands_v2/legacy_compat.rs to offline_cache/downloader.rs as
pub(crate) functions. No behavior change. Prepares the V2-clean path
for the upcoming offline cache manager commands.
cc
Frontend (62 components, ~64 rules):
- Replace hardcoded `color: white` / `#fff` with `var(--btn-primary-text)`
in every rule that paints text on `var(--accent-primary)`. Covers in-rule
cases (BulkActionBar, GenreFilterButton, KeybindingsSettings, settings
views, login, modal primary buttons, wizard steps, etc.) plus descendant
selectors (`.parent.selected .child`).
- Semi-transparent rgba(255,255,255,X) over selected accent rows now use
`color-mix(in srgb, var(--btn-primary-text) X%, transparent)` so the mute
level inherits the per-theme token color.
Theme tokens in app.css (--btn-primary-text fixes):
- rose-pine-dawn: #faf4ed → #575279 (was the bg color, ratio 2.60 FAIL;
now 5.71 AA against coral accent)
- kurosaki: #fbf9f2 → #262424 (was the bg color, ratio 1.76 FAIL;
now 8.33 AA against gold accent)
- snow-storm: #eceff4 → #2e3440 (was the bg color, ratio 3.50 AA-Large;
now 6.13 AA against blue accent)
- stratego: #0a0a0a → #ffffff (visual choice; 4.14 AA-Large is
acceptable for button labels and matches palette intent)
Auto-theme generator (src-tauri/src/auto_theme/generator.rs):
- pick_btn_text_for_accent_set(): WCAG-aware FG selection that holds
across accent / hover / active states. Replaces the broken
`luminance < 0.5` threshold which produced 2.0:1 ratios on mid-luminance
accents (Catppuccin lavender, Dracula purple, rose-pine coral).
- ensure_text_contrast_target(text, bg, is_dark, target): generalize the
iterative contrast helper to accept a target ratio. Apply to
text_secondary (4.5) and text_muted (3.0).
- Add --btn-danger-text and --btn-warning-text tokens via
pick_btn_text_for_bg for future status-button consumers.
Misc fixes:
- SettingsView.svelte: extract initialTitlebarMode to const, avoids the
Svelte 5 state_referenced_locally warning.
- QueuePanel.svelte: wrap .stop-after-marker in :global() (the class is
forwarded to a Svelte component child, not a native element) and fix
var(--accent) typo to the correct var(--accent-primary).
Tests: 13 generator tests pass (8 prior + 5 new). svelte-check 0 errors,
31 warnings (down from 33).
cc
Adds the 4-value titlebar mode selector (qbz | system | plasma | hidden)
replacing the three mutually-conflicting boolean toggles. Plasma mode
delivers KDE-native SSD via Xwayland on Wayland sessions; system mode
now also mounts a stripped strip below OS chrome when the user has
search-in-titlebar or any titlebar-nav item enabled.
cc
Bumped only for src-tauri's qconnect-discovery path. qbz-cast (chromecast/
DLNA) stays on 0.11 because mdns-sd 0.19 changed the address API
(HashSet<IpAddr> → HashSet<ScopedIp>) and adapting qbz-cast is real
work outside dependabot scope. Workspace now carries both versions
in the graph; both compile and operate independently.
cc
Reads the persisted titlebar mode at startup via WindowSettingsStore::new_readonly()
then composes the X11-forcing condition with the existing graphics force_x11
setting. Plasma mode now produces real KWin SSD on Wayland sessions without
requiring users to set the env var externally.
cc
Window builder now derives decorations from TitlebarMode::wants_decorations()
which returns true for System and Plasma. Transparency gate now requires
mode=Qbz so plasma/system/hidden modes do not build a transparent window
that would leak through KDE/system chrome. The v2 command registry is
updated to expose the new mode commands and drops the legacy boolean
setter.
cc
Adds a 4-value mode enum (Qbz | System | Plasma | Hidden) to the existing
WindowSettings store, with column migration that infers mode from the
legacy use_system_titlebar flag. Setter is write-through so the legacy
column stays consistent for any remaining readers.
cc
The auto-connect spawn was firing at the end of the Tauri setup hook,
before the frontend invoked runtime_bootstrap. At that point the API
client is not yet initialized (bundle tokens not extracted, no auth),
so service.connect always failed silently and the lifecycle stayed Off
even when startup_mode was On or Remember-last-on.
Move the trigger into v2_runtime_bootstrap, right after the OAuth token
restore + session activation + CoreBridge auth complete. The CLI
override (--enable-qconnect / --disable-qconnect) is exposed via a
managed QconnectCliOverride wrapper so bootstrap can read it.
cc
The auto-connect spawn was running on a worker thread before CoreBridge
inner init completed, risking undefined behavior in service.connect.
Moving it to the end of setup ensures all init runs first.
cc
Spawns a background auto-connect task during the Tauri setup hook when
the effective startup decision says so. Failures log a warn and leave
lifecycle in Off; no infinite reconnect loop on first-time failure.
cc
Volatile per-launch override. Both flags together → enable wins
deterministically with a warn log. The override is threaded into
lib::run() and consumed in the next task.
cc
Hotfix on top of 1.2.9 to address the gapless-play regression
introduced by dee987c1a's tightened isGaplessTransition gate combined
with a long-standing race in the player's transition-swap atomics.
Bumped:
- src-tauri/Cargo.toml + Cargo.lock
- src-tauri/tauri.conf.json
- package.json + package-lock.json
- snapcraft.yaml (version + source-tag) — packaging/snap/snapcraft.yaml
follows automatically via symlink
- packaging/flatpak/com.blitzfc.qbz.metainfo.xml (new release entry)
cc
Quiet Polish: QConnect cleanup (#316) and a long round of Linux
tray polish. Sticky-header gap fix (no more padding-top on scroll
containers), Edit folder shortcut from the playlist sidebar (#364),
shift-range + Ctrl/Cmd+A multi-select across the remaining views,
opt-in resume of seek position (#317), and Qobuz subtitle/edition
propagated through queue / MPRIS / scrobbling (#360).
Bumped:
- src-tauri/Cargo.toml + Cargo.lock
- src-tauri/tauri.conf.json
- package.json + package-lock.json
- snapcraft.yaml (version + source-tag)
- packaging/flatpak/com.blitzfc.qbz.metainfo.xml (new release entry)
packaging/snap/snapcraft.yaml is a symlink to the root snapcraft.yaml
so it tracks automatically.
cc
The bundled icon.icns was a 1024x1024 PNG masquerading as an .icns
container (placeholder, since no proper libicns tooling is installed
on the build machine). Regenerated from the current icon.svg so the
artwork at least matches the rest of the branding. A real .icns
container with multiple Apple-spec sizes still needs to be produced
by a macOS contributor.
cc
Three issues with the 1.2.9-pre tray icon override:
- The mono PNGs filled the entire canvas, so the tray icon read as
larger than its panel neighbours (Plasma + GNOME).
- The dropdown labels were inverted: picking "Light icon" produced
the dark glyph and vice-versa, because the constants were named
after the panel they suit ("light panel" -> dark glyph), not after
the glyph colour the user expects to see.
- There was no way to pick the colour vinyl logo even on desktops
where a monochromatic glyph clashes with the rest of the tray.
Rebuild the mono pixmaps (22/32/44/64) from icon-symbolic.svg with a
2px content inset; render a new colour set from icon.svg at the same
sizes; rename the Rust constants to glyph-colour names and introduce
an IconVariant enum so the UI label, the persisted value, and the
file selection all line up.
Dropdown options now read Auto / Mono light / Mono dark / Color in
all five locales. normalize_tray_icon_theme on the Rust side and
normaliseTrayTheme in SettingsView migrate the inverted "light" /
"dark" values stored by 1.2.9-pre to the user-intent equivalents.
cc
Auto-detected dark vs light tray glyph fails on GNOME, whose top bar
is permanently dark even when the system color scheme is light, so
the dark glyph disappears against it. Add a persisted user override
("auto" / "light" / "dark") that the SNI tray respects at startup
and on the fly.
Backend pieces:
- TraySettings gains tray_icon_theme: String with "auto" default and
a normalize_tray_icon_theme() helper that clamps unknown values
back to "auto". SQLite migration ALTERs the table to add the
column when an older DB is opened.
- tray_linux_ksni::decode_tray_icons takes an optional override and
resolve_dark_tray() consults it before falling back to the
existing GNOME/GTK/KDE auto-detection. init() forwards it.
- LinuxTrayHandle gains set_icon_theme(theme); a new
TrayUpdate::SetIconTheme variant flows through the dedicated
worker thread so re-decoding plus handle.update() happen outside
the tokio runtime (same panic-avoidance pattern used for
tooltip updates).
- v2_set_tray_icon_theme persists the value, mirrors it to the
global startup store, and pushes it to the live tray handle so
the icon flips without restart.
cc
ksni 0.3.4 with the "blocking" feature wraps every Handle::update in
Runtime::block_on, which panics when called from inside an existing
tokio runtime — exactly where Tauri commands and our async polling
loop run. The panic was being swallowed by the runtime, so set_track
and set_playing looked like no-ops and the SNI tooltip kept rendering
the static "QBZ / Music Player / Nothing playing" fallback.
Move all updates onto a dedicated std::thread that owns the ksni
blocking handle and processes mutations from a std::sync::mpsc
channel, fully outside any tokio context.
cc
Replicate the Plasma media plasmoid tray behavior on Linux SNI panels:
- Tooltip now shows live track title, "by Artist", album line, and
"Middle-click to play/pause" + "Scroll to adjust volume" hints.
- Middle-click (SecondaryActivate) emits tray:play_pause instead of
toggling the window — the window toggle stays on left-click.
- Mouse wheel (Scroll) emits tray:volume_delta with normalised ticks
(~120 SNI units per notch -> +/-5% volume per tick), wired to the
existing handleVolumeChange path on the frontend so QConnect remote
renderers and the local player both respond.
Architecture: ksni 0.3.4's blocking Handle is now stored as
LinuxTrayHandle in Tauri state. v2_set_media_metadata pushes track
fields into the tooltip, and the playback poll loop mirrors the
play/pause flag so the hint flips while the panel popup is open.
cc