Two interactions missing from the original banner landing:
- Dismiss. The banner was always-on while the recommendation differed
from persisted state — fine the first time the user sees it, but
annoying if they read it, decided to keep their tuning, and didn't
want the banner returning every time they opened the Graphics tab.
Adds a Dismiss button that writes the recommendation's rationale
string (joined) to a localStorage key as the dismissed signature.
The banner stays hidden as long as the currently computed
recommendation produces the same signature; if the environment
changes enough to switch matrix branches (different GPU vendor,
X11<->Wayland flip, VM detected), the signature mismatches and
the banner re-surfaces so the user can re-evaluate.
- Manual re-detect. Always-visible "Detect recommended settings"
link above the banner area runs detection on demand and clears
the dismiss flag. If the recommendation already matches the
persisted state (no diff) the user gets a success toast confirming
so — otherwise it would look like the button did nothing.
Apply now also clears the dismiss flag as a hygiene step — after Apply
the diff goes to false and the banner naturally hides, but the stale
signature would otherwise sit in localStorage waiting to suppress a
future legitimately-different recommendation.
i18n: 3 new keys per locale (dismiss / redetect / noChanges) across
en/es/de/fr/pt.
cc
The detection + decision matrix in autoconfig_graphics.rs already knew
the right combination for every (GPU vendor x display server x desktop)
case, but the only way to consume it was the qbz --autoconfig-graphics
CLI tool which most users never run. The recommendation sat invisible
while users hunted toggles by hand.
Lifts the same logic into the Settings UI as a persistent banner at
the top of the new Graphics tab:
- autoconfig_graphics: Environment and Recommendation structs become
public with Serialize, the three pieces (detect_environment,
compute_recommendation, write_recommendation) become pub so they
can be re-used. The existing apply_recommendation CLI helper now
delegates writes to the shared write_recommendation, keeping the
behaviour identical (and inverting the DMA-BUF mapping so that
rec.disable_dmabuf=false now writes force_dmabuf=true, matching
the 1.2.13 opt-in semantics).
- commands_v2/graphics_recommendation.rs (new): two thin Tauri
commands — v2_get_graphics_recommendation returns the environment
plus the computed recommendation so the frontend can compare it
to persisted settings; v2_apply_graphics_recommendation writes
the whole bundle in one call.
- Settings UI: the Graphics tab loads the recommendation on mount.
A $derived computes whether at least one recommended value
diverges from the four persisted settings (HW accel / force_x11 /
gsk_renderer / force_dmabuf with the inverted DMA-BUF axis).
When it does, a violet-accent banner appears above the profile
grid showing the detected environment, the rationale lines from
the matrix, and an Apply button. Persistent (no dismiss) — vanishes
the moment the persisted state matches the recommendation, no
second copy of state to keep in sync.
- i18n: 4 new keys per locale (title / detected / apply / applied)
across en/es/de/fr/pt. The rationale strings stay in English
because they're hardcoded in autoconfig_graphics.rs and reword
them per scenario would mean a structured i18n key per case —
not worth it for diagnostic copy that's read once and dismissed.
cc
Appearance was getting kilometric — themes, layout, immersive panels,
immersive FPS, and an embedded collapsible "Composition" subsection
(GDK / GSK / X11 / Wayland / DMA-BUF / env-var reference) all under one
tab. The Composition piece is its own business unit (GPU infrastructure
that affects whether the app runs, not how it looks), and burying it
inside a collapsible inside an unrelated tab made the recommendation
work in the next commit awkward to surface.
Moves the entire Linux-only Composition block to a new top-level
Graphics section. Flattens the collapsible wrapper since the whole page
is the section now — the collapse-to-summary affordance only made
sense when it was a sub-block. The Graphics nav entry is injected
dynamically after Appearance and only on Linux (matches the existing
gating; macOS/Windows have no GDK/GSK/DMA-BUF surface to expose).
What stays in Appearance: themes, fonts, layout density, immersive
background mode, immersive panels per-panel toggles, immersive FPS
preset. Those control what the user sees, not the GPU infrastructure.
Locale subtrees move with the section — settings.appearance.composition
becomes settings.graphics across en/es/de/fr/pt. Title and summary
strings rewritten for the new "Graphics" framing in each locale; the
~30 leaf keys (hardwareAcceleration, forceDmabuf, profiles, env-vars
reference, etc.) keep their existing translations and just change path.
The unused compositionCollapsed state var drops out as a side effect.
cc
The DMA-BUF renderer was on by default for AMD/Intel users with
specific NVIDIA carve-outs to disable it. The asymmetry was load-
bearing on a fragile feature: when DMA-BUF breaks on Linux the
failure mode is catastrophic (black window, missing textures, full
viewport corruption) rather than degraded, and the auto-detection
couldn't anticipate hybrid Intel+NVIDIA edge cases that surface only
on specific Mesa/NVIDIA driver pairings. The perf gain when it works
is marginal — ~5-10% memory bandwidth on heavy compositing, not
material for a music UI.
Flips the policy:
- DMA-BUF off by default for everyone.
- Users opt in via the existing Settings > Developer >
force_dmabuf toggle (already persisted; just inverts the
semantic from "force even though heuristics say off" to "turn
it on at all").
- QBZ_HARDWARE_ACCEL=1 explicit env override still bypasses
everything as before (power-user escape hatch).
- QBZ_DISABLE_DMABUF=1 still respected for explicit logging.
Removes the NVIDIA-Wayland / NVIDIA-X11 carve-outs because they're
strict subsets of "always off" now. Keeps the Wayland+NVIDIA-only
compositing disable — that's a separate axis (EGL protocol errors
corrupt the whole window, not just texture sharing) and still has
to fire on that specific stack.
User-visible startup log changes from "Using default WebKit
renderer (full hardware acceleration)" to "DMA-BUF renderer off by
default (opt-in via Settings > Developer)" for the common path.
cc
Sister script to dev-cpu-mode.sh. Where the CPU script forces pure
software rendering across every layer that can sneak the GPU back in,
this one pins WebKit / GTK to the integrated GPU (Intel/AMD) so we can
test the "modest HW accel" tier the current detector ignores.
Specifically pins __EGL_VENDOR_LIBRARY_FILENAMES to the Mesa EGL JSON
so the NVIDIA EGL ICD doesn't win (it has the lower 10_* priority
prefix and WebKitGTK prefers EGL). Leaves WEBKIT_DISABLE_COMPOSITING_
MODE, WEBKIT_DISABLE_DMABUF_RENDERER, GSK_RENDERER, and LIBGL_ALWAYS_
SOFTWARE unset — the inverse of dev-cpu-mode — so real iGPU paint
paths exercise.
Header documents verification commands (nvidia-smi must not list
QBZ; intel_gpu_top / nvtop should show activity on Intel only).
Purpose is to inform a future refinement of graphicsState's detector:
binary hardware_accel_enabled doesn't differentiate "iGPU is fine for
balanced visuals" from "no GPU at all, force lite". Manual harness
first; detector smarts second once we know what actually works on
real iGPU paint.
cc
Follow-up to #423. The new app-wide user-select: none rule covers
toasts too, which is correct for ephemeral info toasts but loses
copy-paste from error toasts — those carry track ids, file paths,
and backend error strings that we routinely copy into issues and
diagnostics.
Adds .selectable to the toast container so all toast types stay
copyable. Trade-off accepted: a stray drag-to-select gesture on a
success toast won't initiate a window drag, but error visibility
matters more than that edge case.
cc
Today the startup orchestration skips the local session restore whenever
qconnectRuntimeActive — defensive against the visual flicker of seeing
the last local queue and then watching QConnect overwrite it. That left
QConnect users staring at "No track playing" while the WebSocket
bootstrapped, and the queue was effectively unrecoverable if QConnect
failed to push state.
Inverts the policy per #315: always restore the local snapshot. When
QConnect is bootstrapping at launch, the restore is held back behind a
1s flicker guard timer. The QueueUpdated event listener cancels the
timer if the remote queue snapshot arrives first — the user sees only
QConnect state, no flicker. If 1s elapses without a remote snapshot
(slow network, server hiccup, controller offline), the timer fires and
local restore proceeds so the user isn't stuck.
Two related wires:
- restoreLastView runs unconditionally now (view restore is unrelated
to playback source, shouldn't be gated on QConnect).
- The QConnect listener also calls debouncedFullSessionSave after a
successful QueueUpdated sync, covering the case where QConnect
reorders/replaces upcoming tracks while the current_track stays the
same — the track-change effect wouldn't fire, and without this hook
the local snapshot would drift behind remote.
Acceptance from issue #315: queue persistence on every mutation
(prior commit), QConnect state read at startup (already wired via
bootstrap_remote_presence → AskForQueueState → QueueStateChanged),
QConnect wins on divergence (cancel-guard makes this the typical
path), local-only path unchanged when QConnect is off (else branch).
The auto-advance / delete-timing guard logic in corebridge.rs and
commands_v2/playback.rs is untouched — the change lives in the route
component's restore orchestration, not in the QConnect runtime.
cc
The PersistedQueueTrack struct already had streamable and parental_warning
fields but the load path hardcoded streamable=true and parental_warning
=false because the columns didn't exist in queue_tracks. source_item_id
_hint was missing entirely.
Adds the three columns via the same idempotent ALTER-TABLE pattern the
existing migrations use, persists them in save_session, and reads them in
load_session. PersistedQueueTrack gains source_item_id_hint as an
Option<String> with serde default so saves from older builds deserialize
cleanly.
User-visible effect: a restored queue now keeps explicit-content badges,
unstreamable visual state, and Mixtape/Collection source-item ids
matching the live queue instead of resetting to defaults at every
launch. Backwards-compatible: columns get safe defaults for older rows
(streamable=1, parental_warning=0, hint=NULL) so users upgrading from
1.2.12 don't see a regression.
cc
The local session restore today saves the full queue only on track-change
(debounced 2s), pause, clear, the 15s playback tick, and app-close. Pure
queue mutations — add/addNext/setQueue/clearQueue/moveQueueTrack/
removeAfter/stop-after-consume — left the persisted queue stale until the
next track boundary, which meant a crash mid-album lost any tracks
queued after the playing one.
queueStore exposes registerQueueMutationObserver so the route component
can plug debouncedFullSessionSave into the mutation path without the
store knowing about session-save internals. Each mutator that funnels
through syncQueueState now also calls notifyMutation, the observer
coalesces via the existing 2s debounce, and the SQLite write happens on
the same path the track-change save already uses (single source of
truth, idempotent on rapid mutations).
The cursor-only mutators (next/previous/playQueueIndex) intentionally
do NOT notify — they are already covered by the track-change effect at
+page.svelte:5055 which saves on current_index change.
cc
The /track/getList endpoint caps at 50 IDs per call. Sending more in a
single POST either errors out or silently truncates, both of which take
the entire call site down.
For QConnect this surfaced as: pushing a >50-track playlist from the
Qobuz Android app to QBZ caused the materialization step (corebridge
get_tracks_batch hydration) to return an error, abort, and leave only
the 1-track placeholder queue from the earlier align-cursor pass. The
visible symptom was "only the first track loads on Connect playlists
above 50 tracks".
The fix is at the helper level rather than in QConnect: split inputs
larger than 50 into 50-ID windows fetched serially, preserve input
order in the returned Vec. Single-window path is unchanged for the
common case. Same return shape, same error type, so the four call
sites (corebridge.materialize_remote_queue, qbzd qconnect, V2
v2_get_tracks_batch, qbzd catalog) all benefit transparently without
edits to QConnect orchestration code (auto-advance, deletion timing,
queue ordering are untouched).
Serial loop instead of the concurrent fan-out used by get_playlist —
lower risk for the first fix; if QConnect playlists of 200+ tracks
start feeling slow we can revisit with try_join_all.
cc
Clearing the error flag also drops any pending stream_error_message.
A fast record_stream_error -> set_stream_error(false) -> polling-tick
sequence will silently swallow the toast — that is the intent (a
recovered error is not user-actionable), but it surprises future
readers, so document it inline.
Follow-up to #418 (karaoke playback overhaul). In CPU mode the
background-clip: text repaint plus the per-frame --line-progress
interpolation that drives the gradient stop are both costly under
software compositing. Immersive already swaps the gradient for solid
white (kept), but sidebar/static/compact surfaces still ran the
expensive path.
Adds a global override under html.no-hwaccel that zeroes the gradient,
restores a solid accent color, and kills the --line-progress transition.
Active-line emphasis still reads via the font-size/weight/scale from
.lyrics-line.active, which is unchanged. The immersive solid-white rule
keeps winning on specificity (0,0,5,0 vs 0,0,4,1), so this only takes
effect on the non-immersive surfaces where the gradient was the cost.
cc
Adds a tri-state Select-All checkbox in the Tracks tab toolbar of
FavoritesView, mirroring the local-library tracks tab. State is computed
over the currently filtered tracks so genre/search filters narrow the
selectable set, matching user expectation for the visible-set semantics
established in #381.
cc
Completes the multiselect rollout in LocalLibraryView and the folder/album
view:
- Select-All checkbox in the folder-album track listing, mirroring the
tracks-tab toolbar pattern.
- Shift-click anchor and ctrl-a effect for track rows in the folder-album
view.
- Cross-context mutual exclusion ("apagador") between tree-select,
album-select, track-select and the folder-album view's selection mode:
entering any context clears the others' selections and turns off their
selection mode, so two contexts cannot be active simultaneously.
The mutex is plumbed via parentSelectionActive prop + onSelectionEntered
callback between LocalLibraryView (orchestrator) and the
LocalLibraryFolderAlbumView child, matching the simpler single-switch
mental model the user requested.
cc
subscribeLyrics fires per rAF tick (~60Hz with extrapolation). The
previous version wrote all six $state slots unconditionally, which
re-triggered the lyricsSidebarLines $derived (a .map allocating N
objects per call) every tick even though lyricsLines itself only
changes per-track. Adding reference/value guards short-circuits the
fan-out for unchanged fields — the per-tick path now only writes
lyricsActiveProgress (and sometimes lyricsActiveIndex).
Two related fixes to the active-line rAF loop:
- The previous guard reassigned updateRafHandle *after* updateActiveLine
returned. Any listener that synchronously re-entered
startActiveLineUpdates during notifyListeners() saw a null handle and
scheduled a second parallel rAF chain. Now the handle is claimed
before the work runs, so re-entry is a no-op.
- Drops the inter-tick progress threshold. With playerStore extrapolating
audio time between backend events, every rAF tick has a freshly-changed
progress value. Threshold-throttling would skip the edge samples where
the snap-to-1 and indexChanged transitions need to land — re-introducing
the "starts midway"/"ends before 100%" bugs. Downstream consumers
equality-guard their $state writes, so fan-out only fires on real
changes.
The Rust backend emits playback:state at 250ms cadence. Frontend
getCurrentTime() was returning that staircase value — for the karaoke
rAF loop running at 60Hz, every 15 frames got the same answer, then a
250ms jump. This caused two visible artifacts:
- Active line appears to "start midway" when detection of the new index
lags by up to 250ms past the line's timeMs (audio is already that far
into the line by the time we see the boundary).
- Active line gets demoted before its highlight reaches the end, because
the last in-line notification reports progress ~0.92 (one tick before
bound) and the next backend tick has already crossed into the next
line — the snap-to-1 never gets a chance to fire.
Audio position is wall-clock-based on the backend (see the 250ms emit
loop comment in lib.rs); we now do the same math in JS: anchor
(positionSecs, performance.now()) on every event-driven update, then
return anchor + elapsed while playing. Cap extrapolation at +1s so a
stalled backend doesn't run away. Existing 33 playerStore tests pass
unchanged — they read currentTime directly, not getCurrentTime().
Pre-pretext, the karaoke gradient applied to a single span shared the same
horizontal cut across every wrapped visual line — line 2 appeared partially
sung when only line 1's worth of vocals had played. Pretext lays out the
active lyric into visual lines; each renders as its own block-level span
with its own 0->100% gradient, so progress fills sequentially.
Also adds race/perf safeguards layered on top:
- Cancellation token on tick().then(layoutActiveLine) so rapid activeIndex
changes (seek-scrub, fast songs) don't queue stale microtasks against
the current DOM.
- ResizeObserver coalesces via rAF and ignores sub-pixel width deltas, so
window-drag and the active-line scale animation don't trigger Pretext
re-measurement every callback.
- segmentBounds prefix array memoizes cumulative widths, dropping per-
segment progress lookup from O(i) to O(1) inside the each-block.
- EMA writes equality-guard measuredBlockMs/Delta and lookaheadEnabled so
Svelte signals don't fan out no-op updates 60 times per second.
- Lookahead clamped at 1/30 and disabled on backward dp so a coarse audio
jump or in-line seek can't poison the visual lead.
- measuredBlockMs seed lowered (>=20ms, dur * 0.01) since rAF-rate data
converges the EMA within 2-3 ticks; the prior 80ms floor visibly lagged
the gradient on the first frame of every new line.
- LyricsLine type unified from store (single source of truth).
Listen for the backend audio:init-failed event and show a user-facing
error toast (8s) with the backend-provided cause. Mirrors the
audio:device-missing listener pattern alongside it. Surfaces the
silence when macOS CoreAudio init fails, instead of leaving the user
guessing why playback didn't start.
Add localized strings for the new backend audio init failure toast
(en, de, es, fr, pt). Reason placeholder receives the backend's
already user-friendly message (e.g. "macOS audio device changed its
sample rate during stream open...").
The playback-event polling loop now drains
SharedState::take_stream_error_message and emits a Tauri
audio:init-failed event with the user-readable cause. Drain semantics
guarantee the toast fires exactly once per error instead of every
tick of the polling loop.
Pairs the existing stream_error AtomicBool with an Option<String> that
the audio thread can populate when a stream init failure has a
meaningful cause to surface to the user (currently: the macOS
CoreAudio rate-mismatch path).
set_stream_error(false) also clears the message; record_stream_error
sets both atomically; take_stream_error_message drains so the UI
fires its notification exactly once per error.
evaluate_stream_recreate runs on every track change on the audio
thread, and each call issued two HAL syscalls
(resolve_output_device_id + get_nominal_sample_rate). Cache the result
per-thread for 750ms — long enough to absorb the bulk of repeats
within an album, short enough to notice a system rate change.
Cache invalidates on device-name change, so switching the output
device still triggers a fresh query.