Three bugs were stacking up to make session resume drop the saved offset
for streamed Qobuz tracks:
1. Position save was debounced, not throttled. Each playback tick reset
the 5s timer, so the save never fired while a track played
continuously — the persisted position froze at whatever value got
written during the first quiet window after track start (typically
~2s). Switch to throttle: fire the first call immediately, then once
per 5s window, with a tail call to flush the latest value when the
window ends.
2. togglePlay tried to apply the restore offset via a separate cmdSeek
right after cmdPlayTrack. For Qobuz streaming the audio thread's
Seek handler bails when current_audio_data is unset — streaming
uses current_streaming_source instead — so the seek was silently
dropped and the track started at 0:00. Pass start_position_secs
directly to v2_play_track. The streaming backend (legacy + V2
player) waits for enough buffer to cover the offset plus 8s
headroom, then eagerly decode-and-discards decoder output up to
the offset before engaging the audio engine — no race, no
first-pull underrun. Plex and local tracks keep the old play+seek
path; they load full audio_data so Seek works as-is.
3. The seekbar showed 0:00 from app launch until the user hit play,
even when a saved position was queued. setPendingSessionRestore
now pre-positions currentTime when positionSecs > 0 so the UI
reflects "paused at X" the moment the app opens.
Other plumbing:
- AudioCommand::PlayStreaming carries start_position_secs and
content_length (used to estimate bytes-per-second for the resume
buffer target).
- Player::play_streaming{,_dynamic} grow a start_position_secs
parameter; QConnect and api_server callers pass 0 (remote-driven
playback always starts fresh).
- cmdPlayTrack forwards startPositionSecs as a 5th optional arg.
cc
Solves the long-running performance complaints (especially Optimus
laptops where the dGPU silently hijacks WebKit). The end-to-end shape:
- graphics_detection enumerates physical GPUs from /sys/class/drm and
cross-references EGL vendor JSONs under /usr/share/glvnd/egl_vendor.d
to expose only stacks the running process can actually use. Works
inside Flatpak/Snap because /sys is still mounted read-only and the
sandbox-visible EGL vendor JSONs are the right thing to read.
- preferred_gpu column in graphics_settings.db (string: auto, integrated,
discrete, software, or a specific PCI slot id). Migrated with
ALTER TABLE ... DEFAULT 'auto'.
- env_vars_for_preferred_gpu returns Set/Unset actions applied at startup
before WebKit init. Integrated/AMD path explicitly unsets
__NV_PRIME_RENDER_OFFLOAD so KDE/Optimus sessions that pre-set it to 1
no longer override the EGL pin and yank rendering back onto NVIDIA.
- Auto resolver: on hybrid systems (iGPU + dGPU both usable), Auto pins
to the iGPU. Single-GPU and no-GPU systems remain no-op. The dropdown
surfaces what Auto resolved to ("Auto (Intel Graphics)") so the user
sees the decision without picking explicitly.
- boot_watchdog writes a pending.json before WebKit init listing the
risky knobs in effect. Frontend calls v2_mark_boot_succeeded after
first paint to clear it and promote to last-good.json. If the next
launch sees pending without a success ack, it blames matching fields,
reverts them in-DB, and increments per-knob failure counters; two
consecutive failures lock the knob until the user clears it.
- Settings > Graphics gets a Rendering GPU dropdown (between HW accel
and DMA-BUF) and a crash-recovery banner that lets the user re-enable
any auto-disabled knob. Restart-required affordance shared with the
other risky toggles.
- i18n keys for the dropdown, banner copy, and autoWithTarget label
across all five locales.
cc
180x180 card with a 114x114 artwork inset, palette extraction for the
background gradient and mix-blend-mode multiply on the PNG bezel so the
station artwork sits in front of a cohesive colored frame. RADIO label
uses padding-left compensation to align with the visible bezel edge,
not the raw container. Hover overlay blurs the artwork and surfaces a
play button — same pattern as the iOS reference shipped in c8cee326.
cc
autoconfig_graphics gains a hybrid Intel/AMD + NVIDIA branch that keeps
GSK renderer on Auto and DMA-BUF off so the iGPU handles compositing
without the dGPU getting dragged in. detect_gpu_name is exposed and
labels hybrid setups explicitly (e.g. "NVIDIA (driver 580) + Intel
(i915)"). RuntimeDiagnostics gets runtime_gpu_name and
runtime_desktop_environment so the Diagnostics panel can show what the
app sees at runtime, which is the first thing useful to triage
performance reports from log uploads.
cc
The Library Albums tab carried three different quality-badge designs
across three rendering paths (grid card, list row, album detail
header), each rendered as raw text — visually disconnected from the
QualityBadgeStatic that Mixtape and Collection lists already use, and
the Plex source icon was inconsistent (present on grid cards, missing
on list rows and the album detail header).
Three coordinated swaps so all surfaces speak the same visual
language:
- AlbumCardLibraryLite (Library grid + Artist Detail tile). The
custom .quality-pill text badge is replaced by
<QualityBadgeStatic iconOnly>, matching the Discovery V2 layout —
icon-only square sitting to the right of a two-line title/artist
stack. Adds format / bitDepth / samplingRate props so the badge
can render its real tooltip ("FLAC: 16-bit / 44.1 kHz") instead
of falling back to the parsed quality string.
- VirtualizedAlbumList list mode. The plain-text .quality-badge
span is replaced by <QualityBadgeStatic> full mode (the
Mixtape/Collection badge), plus an inline <SourceBadge> when
showSourceBadge is true so Plex / Qobuz Purchase / Qobuz Download
surfaces are recognizable in the row. resolveSourceBadge maps
the LocalAlbum.source string onto the narrow
LibrarySourceBadge enum the card prop expects.
- LocalLibraryView album detail header. The audio-specs strip now
ends with a <SourceBadge> tied to selectedAlbum.source. Pairs
naturally with the FLAC / 16-bit / 44.1 kHz pills already there
and finally lets the user spot at a glance whether the album
they're looking at came from a network Plex server or local
disk.
Call sites in LocalLibraryView (the renderMetadataAlbumCell snippet
and the Artist Detail tile loop) pass album.bit_depth and
album.sample_rate / 1000 alongside the existing quality string so
the badge tooltips read accurate numbers. VirtualizedAlbumList
forwards the same trio to its inner AlbumCard.
cc
The Plex aggregation CTE used MAX(codec)/MAX(bit_depth) directly,
which produced two bugs that surfaced in the Albums grid for many
Plex-only libraries:
- format = "Unknown". Plex frequently omits the stream-level codec
when its analyzer hasn't seen the file yet. MAX(NULL) collapsed
the row's format to NULL, which the frontend parser mapped to
AudioFormat::Unknown — the Library grid then showed UNKNOWN even
though the file's container was clearly "flac" / "mp3".
- bit_depth = NULL. Plex commonly omits bitDepth on older releases
entirely; the album row inherited the gap and the UI rendered
"--/44.1" next to an otherwise valid quality string.
Two fallbacks applied to the plex_aggregated CTE only — the local
CTE is untouched because local indexing always writes a non-null
format string and a real bit depth via Symphonia:
- format: COALESCE(MAX(codec), MAX(container)). The `container`
column is populated for the same media and typically carries the
same value ("flac", "mp3"), so it's a safe fallback when codec
is fully NULL across the album.
- bit_depth: COALESCE(MAX(bit_depth), CASE WHEN <lossless> AND
sample_rate <= 48000 THEN 16 ELSE NULL END). Lossless formats
at CD-range rates are virtually always 16-bit redbook, so we
default to 16 in that narrow window. Higher rates stay NULL so
we don't lie about 24 vs 32. The per-track view shows the real
value when the user clicks in (lazy hydration via
plex_get_track_metadata).
cc
The bottom-right song card (artwork + title + album + artist + quality
badge) was duplicated inline in nine visualizer panels — LineBed,
CometFlow, NeonFlow, EnergyBands, Oscilloscope, Lissajous,
SpectralRibbon, TransientPulse, and TunnelFlow. Each carried its own
HTML structure and ~90 lines of identical CSS for the song-card
chrome. Any visual tweak (badge swap, layout shift, album/artist
inline join, artwork sizing) had to be replayed nine times and was
easy to miss one — the recent LineBed-only inline-album·artist change
exposed exactly this trap.
Extracts the song card into src/lib/components/immersive/Immersive
SongCard.svelte, which owns its own absolute positioning, the
two-line text stack with the new "album · artist" subline, the
QualityBadgeStatic mount, the 72px artwork thumb, the explicit-badge
overlay, and the responsive breakpoint. Each consumer panel now drops
in `<ImmersiveSongCard {artwork} {trackTitle} {artist} {album}
{explicit} {quality} {bitDepth} {samplingRate} {format} />` instead
of carrying its own copy.
Net effect: 268 insertions across all ten files vs 948 deletions of
duplicated markup and CSS — and the next visual change touches one
file, not ten. SuggestionsPanel uses a totally different layout
(queue list) and is intentionally NOT migrated.
cc
JavaScript bitwise shift operates on 32-bit signed integers, so
`1 << 48` is `1 << (48 % 32)` = `1 << 16` = 65536. Every Qobuz track
id sits above that, so the existing `id >= EPHEMERAL_ID_FLOOR` checks
were classifying every Qobuz track as ephemeral and disabling the
favorite / playlist / mixtape / collection actions for them — most
visibly in the immersive player favorite button, but also silently
stripping Qobuz tracks from the persisted session queue.
Five places shared the same broken constant, all now use `2 ** 48`
(281474976710656) — the actual ephemeral id floor defined in
src-tauri/src/ephemeral_library/mod.rs:
- src/routes/+page.svelte (two metadataActionsDisabled checks for
the immersive player and now-playing bar, plus the session-save
EPHEMERAL_ID_FLOOR filter).
- src/lib/components/QueuePanel.svelte (isEphemeralQueueTrack guard
that hides favorite / playlist / track-info actions for queue
rows).
- src/lib/stores/playerStore.ts (isEphemeralTransition flag in the
gapless carve-out and CUE virtual-track position translation).
- src/lib/components/views/LocalLibraryView.svelte (close-folder
cleanup check — only ephemeral folders should wipe the queue +
now-playing slot when closed).
Each call site keeps a short comment naming the trap so future edits
don't reach for the same bitwise expression.
cc
CSS-only flex stretch + aspect-ratio collapsed instead of sizing the
thumb against the track-meta column — the previous two attempts
landed with no visible change (the thumb stayed near its old fixed
72px or fell back to image-intrinsic). aspect-ratio interacts poorly
with align-self: stretch when the item has no natural width to drive
the stretched dimension off.
Drops the CSS-only attempt. Binds clientHeight on .track-meta with
Svelte's bind:clientHeight, derives 98% of that into artworkSize, and
applies the result as inline width/height on the thumb. Reactive —
when the badge or text lines re-flow, the column height changes and
the thumb resizes with it. No fallback fixed size needed because the
{#if artwork && artworkSize > 0} guard avoids the first-paint flash
before the bind resolves.
cc
The previous attempt wrapped the thumb in a flex container with the
inner thumb at height: 98% — percentage height didn't resolve
because the wrapper's own height was content-determined, leaving the
inner thumb at near-zero and falling back to natural width. Visual
result: artwork stayed near the old fixed size, much smaller than
the column.
Drops the wrapper, sets align-self: stretch directly on the thumb so
it inherits the column height through the parent's align-items:
stretch chain, and uses aspect-ratio: 1 with auto width so the thumb
stays square. The 2% breathing room comes from margin-block: 1px,
which subtracts 2px from the stretched height in a way percentage
sizing couldn't reach.
cc
The bottom-info song card on LineBed pairs a four-row column (title +
album + artist + quality badge) with a square artwork thumb. With the
quality badge added the column grew to ~100px while the thumb stayed
at its old 72px fixed size, breaking the visual weight balance — the
column dominated the card and the thumb read as an afterthought.
Wraps the thumb in a flex container that stretches to the column
height, then sizes the thumb to 98% of that height with aspect-ratio
1 so it stays square. The 2% breathing room reads as a deliberate
gutter rather than a flush match, and the thumb sits vertically
centered against the column instead of pinned to either edge.
The fixed 72/56 dimensions and the mobile media-query override drop
out as a side effect — the thumb now scales with the actual content
height, so the breakpoint clamp isn't needed.
cc
Visual check on real cards showed 8px was overcorrecting — the framed
icon ended further left than the visible Hi-Res mark instead of at
the same X. Halving the gutter lands closer to where the SVG's
transparent right space actually ends.
cc
The Hi-Res SVG canvas is 48.4x48.4 square but the visible "Hi-Res
AUDIO" mark occupies only ~65% of the width — there's permanent
transparent gutter to the right that we can't strip without editing
the SVG asset. That made the framed (CD/MP3) variant look like it
sat further right than the Hi-Res variant when both ended their
containers at the same X.
Two tweaks so both badges terminate at the same visual X:
- Hi-Res shrinks 1px in height (proportional): 46x30 -> 44x29.
Closer to the visual weight of the framed icon once both share
the same baseline.
- Framed gains margin-right: 8px, matching the SVG's transparent
right gutter so the visible icon ends at the same horizontal
position as the visible Hi-Res mark.
cc
The Discovery V2 cards rendered quality as a custom yellow gradient
pill underneath the title — visually disconnected from the badges used
in Mixtape and Collection lists, ugly at small sizes (the Artist
Spotlight section was the worst offender), and not honoring the same
hover tooltip pattern users are used to from the rest of the catalog
surfaces.
QualityBadgeStatic gains an `iconOnly` mode that mirrors Qobuz's web
client: for Hi-Res tier the badge renders the brand SVG bare (no
surrounding chrome — the logo carries its own); for CD / MP3 the
format icon sits centered in a small framed square the same height
as the Hi-Res mark. Hover surfaces the same tier + bitrate tooltip
as the full badge.
AlbumCardLite restructures the post-cover area into a single meta-row:
a two-line text stack (title + artist) on the left with truncation,
the icon-only badge on the right vertically centered across both
lines. The card's parent gap (4px) applies once between the cover and
this row, so the badge inherits the same separation from the album
art that the text already had.
The data adapters in discovery-v2/data.ts now propagate bitDepth and
samplingRate alongside the existing quality string / isHiRes flag so
the badge tooltip reads "Hi-Res: 24-bit / 96 kHz" with real numbers
instead of defaults parsed back out of the quality label.
Popular Albums uses the AlbumRowLite row layout and explicitly drops
the badge — the row already carries the play action and rank cell,
adding a quality mark crowded the line without earning its space.
AlbumRowLite loses its quality / isHiRes props as a cleanup since the
single consumer (DiscoveryView's mostStreamed snippet) no longer
passes them.
Sizing: Hi-Res 46x30, framed 30x30 — both square-ish, both 30px tall,
so they line up cleanly under each other when grids interleave both.
cc
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
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
Comprehensive set of changes that make immersive mode usable on
machines without hardware-accelerated GPU compositing (Wayland NVIDIA
with the broken driver path, software-rendered llvmpipe, VMs, etc.).
Detection lives in `graphicsState.ts` — when `bootstrapGraphicsState()`
resolves and `hardware_accel_enabled` is false, we add an
`html.no-hwaccel` root class. Every CPU-mode rule below gates itself
on that class so GPU users see zero change.
Chrome-level work (ImmersivePlayer wraps every panel):
- Blanket CSS rule on `.immersive-player` and descendants kills
every `transition`, `animation`, `box-shadow`, and `text-shadow`.
Box-shadows with 32-60px blur radii were the dominant per-frame
cost — confirmed game-changer when stripped (user-tested on
Visualizer with `0 8px 32px + 0 20px 60px` halo around the
artwork).
- `.player-bar`, `.window-menu`, `.tabs`, `.viz-dropdown`,
`.viz-submenu`, and the quality-badge backdrop become opaque
`#000` and drop `backdrop-filter` (40px / 16px / 12px blurs).
- Whitelisted animation exceptions: the 4-bar equalizer in
Coverflow's now-playing indicator (4 x 3px scaleY, basically
free) and `fadeInUpLite` for LyricsFocus line changes
(opacity + transform only, no `filter: blur`).
Per-panel LOW_PROFILE flag (captured from `isHardwareAccelEnabled()`):
- Oscilloscope: skip `shadowBlur: 8` glow, fake with fat-underlay
+ thin-opaque stroke pair; force dpr=1.
- Lissajous: skip shadowBlur: 6 with same trick, trail 8 -> 3
frames, drop the 85-fillRect-per-frame dot cloud, hide the
blurred watermark, dpr=1.
- EnergyBands: drop per-band energy-driven shadowBlur (up to
~20px x 5 rings); halo-arc + ring trick; dpr=1.
- Visualizer: taller cubes (14px vs 6px, half the count per bar);
drop the per-cubelet gradient entirely so the whole frame uses
a single `fillStyle`; dpr=1.
- Coverflow: snap side-cover transitions (only `.center` keeps
its 500ms transform); neutralize hover transforms on side
covers (rotateY + scale 1.05 forces re-rasterization under
software perspective); side covers go to opacity:1 so they
don't pay alpha compositing.
LyricsLines split mode in immersive: restore line transitions for
opacity, color, and transform (all compositor-only). Skip the
font-size transition because that one forces re-layout per frame.
New per-panel enable/disable store (`immersivePanelsStore.ts`):
- localStorage-backed `Record<ImmersivePanelId, boolean>`.
- In CPU mode the five inherently-heavy panels default to
disabled: AlbumReactive, NeonFlow (Laser), TunnelFlow,
CometFlow, TransientPulse. These rely on shaders, particle
systems, or dense per-pixel filter passes that simply cannot
work in software compositing.
- `applyCpuModeDefaultsIfNeeded()` re-applies defaults after
`bootstrapGraphicsState()` resolves, so first-launch CPU users
don't have to relaunch to see the heavy panels hidden. Wiring
lives in `bootstrap.ts` to avoid a circular import between
the store and graphicsState.
- ImmersiveHeader filters the visualizer dropdown and Neon
submenu by enabled state; if all three Neon variants are off,
the submenu item disappears entirely. The `Visualizer` main
tab falls back to the first enabled viz option instead of
hardcoded spectral-ribbon.
First-time CPU-mode warning modal (`CpuModeWarningModal.svelte`):
- Built on ModalLite. Triggered from `+page.svelte` via $effect
when immersive opens, HW accel is off, and the user has not
dismissed the warning before. Disarmed for the rest of the
session after fire.
- "Don't show again" checkbox writes
`qbz-cpu-mode-warning-shown-v1` to localStorage when checked.
- Explains which panels are hidden and points to Settings ->
Appearance -> Composition and -> Immersive Panels.
Settings UI: new "Immersive Panels" collapsible section above the
existing "Immersive FPS" section in Settings -> Appearance. Per-panel
toggle, with a GPU badge + warning paragraph on the five heavy panels
when running in CPU mode.
QualityBadge unification (out of CPU-saver scope, addressed in same
branch by user request): every immersive callsite that previously
used the reactive `QualityBadge` (compact or full) now renders
`QualityBadgeStatic` — the same component used in Collection /
Mixtape detail lists. Sixteen callsites total: nine canvas panels
(compact mode), five center-content panels (full mode), plus
ImmersivePlayer split mode and ImmersiveControls. `QualityBadgeStatic`
gains `text-align: start` on its `.badge-text` block so it renders
correctly inside parents that center their content (VisualizerPanel,
StaticPanel, etc.).
i18n: new keys for `settings.appearance.immersivePanels.*` (title,
summary, desc, cpuWarning, heavyTooltip, warningModal.{title,body,
hint,dontShowAgain,acknowledge}) across en/es/de/fr/pt.
cc
User request: show the album cover thumbnail next to each track in
the Local Library Tracks and Folders list views (same UX the Qobuz
tracks and playlist views already offer).
Default OFF per the 2026-05-12 user call: a library with 16k+ local
tracks would render that many decoded images per scroll session,
and the user shouldn't opt into that without knowing. The setting
lives in `libraryPerformanceStore` (`showTrackArtwork: false` by
default) alongside the other library-perf knobs.
Frontend wiring:
- `libraryPerformanceStore.ts` — new `showTrackArtwork` field,
`isTrackArtworkEnabled()` getter, plumbed through `updateSetting`.
- `LocalLibraryView.svelte` — subscribes to the store, passes
`showArtwork={showTrackArtwork}` and a
`getArtworkUrl={(track) => getArtworkUrl(track.artwork_path)}`
bridge to `VirtualizedTrackList`. `VirtualizedTrackList` and
`TrackRow` already supported these props (used by Playlist
detail view).
- `SettingsView.svelte` — new toggle in Appearance, next to the
"Show playlist cover collages in sidebar" toggle. Uses the same
Toggle component and same persistence pattern.
i18n: new `settings.appearance.localLibraryTrackArtwork` +
`settings.appearance.localLibraryTrackArtworkDesc` keys across all
five locales.
cc
User-reported: Local Library Albums view showed the folder name as
the album title even when the album tag in the track metadata had
a different value — e.g. folder `Alle Songs` shadowed metadata
`ALBUM.`. Cause: the three metadata-grouped album queries (full-load
`get_albums_metadata_grouped`, chunked `get_albums_metadata_page`,
and `count_albums_metadata_for_page`) projected the title via
`COALESCE(album_group_title, album, 'Unknown Album')`.
`album_group_title` is the snapshot the scanner wrote — it
contains the tag's album when the tag was present at scan time,
otherwise the folder name. So whenever a tag was missing or
changed after scan, the folder name would win over the live
`album` column.
Fixed the priority by querying `album` first and falling back to
`album_group_title` only when the live metadata is empty / NULL /
'Unknown Album':
COALESCE(
NULLIF(NULLIF(TRIM(album), ''), 'Unknown Album'),
album_group_title,
'Unknown Album'
)
Folder-grouped queries (which use `album_group_key` first and
intentionally prefer folder-derived titles) are unchanged.
Closes#411
cc
Two related bugs surfacing after the AlbumCardLite migration in
Search > Albums:
1. `albumGridCols` over-computed the column count because the
constant `ALBUM_CARD_WIDTH = 210` was stale — AlbumCardLite is
actually 220px wide post-migration. The math thought N+1 cards
fit per row when only N did, the last card per row wrap-around
to a visual second line inside the same virtual-scroll-item,
and visually piled on top of the next virtual row's cards.
Bumped the constant to 220.
2. `AlbumQuickMenu` (the kebab popover) used `position: fixed` but
was rendered inline inside the card, which lives inside the
virtual scroller's `transform: translateY(...)` wrapper. Per the
CSS spec, `position: fixed` resolves relative to the nearest
ancestor with a transform, not the viewport — so the menu
opened way off where the user clicked. Added a small `portal`
action that appends the menu to `document.body` on mount,
keeping it in viewport coordinates regardless of where the
trigger lives in the tree.
Bonus: reverted the speculative `contain: paint` on `cover-wrap`
from 5045096e — that was attacking the wrong symptom (the user
reported the issue was rooted in #1 above, not in cover-image
clipping). `contain: paint` would add a per-card layer cost under
SW compositing without buying anything now that the real cause is
fixed.
cc
Third pass at the SearchView > Albums overlap bug. `overflow: hidden`
+ `position: absolute; inset: 0` on the img weren't enough to keep
some artwork inside the 220x220 box — the `cachedSrc` action applies
`transform: translateZ(0)` to the img under HW acceleration (the
WebKit 2.50+ texture-eviction workaround) and the composited layer
that creates can escape the parent's clip rect in certain layouts.
`contain: paint` on the parent forces it to be a paint containment
boundary at the rendering-layer level — anything painted by
descendants, composited or not, gets clipped to this box.
cc
Two cleanups in one:
1. Cover img positioning. User reported some portrait-aspect album
artwork in Search > Albums was visually escaping cover-wrap's
`overflow: hidden` and bleeding upward into the row above. The
img was `width: 100%; height: 100%; object-fit: cover` but in
normal flow, while `cachedSrc` applies `transform: translateZ(0)`
to it under HW acceleration (the WebKit 2.50+ texture-eviction
workaround). Combined, the transform was lifting the img into
its own composited layer in a way that escaped the parent's
clip box for some configurations.
Fix: `.cover` (and `.cover-placeholder`) are now
`position: absolute; inset: 0`. Same containment pattern
AlbumCardLibraryLite already uses; strictly bounds the painted
image to the 220x220 cover-wrap box regardless of any transform
the image carries.
2. HomeView.svelte and ForYouTab.svelte are deleted. They were
legacy /home renderings that DiscoveryView (Discovery V2)
replaced — `+page.svelte` only mounts DiscoveryView under
`activeView === 'home'`, and the only remaining `HomeView` and
`ForYouTab` references were the two files referencing each
other. The legacy `AlbumCard.svelte` (used only by those two)
goes with them. `AlbumMenu.svelte` stays for now — `AlbumDetailView`
and `PlaylistDetailView` still use it for page-header menus.
cc
User-reported visual bug after the AlbumCardLite migration: some
album covers in Search > Albums appeared to overlap into the row
above. The `.albums-grid-row` flex container used the default
`align-items: stretch`, which stretched each wrapper to the tallest
card in the row. With AlbumCardLite's optional Hi-Res pill the
rows had heterogeneous heights, and stretched wrappers let the
absolutely-positioned cover from neighbouring cards visually
encroach on previous rows.
Two clamps:
- `align-items: flex-start` on `.albums-grid-row` — every card's
cover top-aligns to the row baseline.
- `width: 220px` on `.album-card-wrapper` (replaces the old
`min-width: 160px`) — matches AlbumCardLite's intrinsic width
exactly, no flex stretch room left.
cc
Profile C path (purchased albums grid). Reused
`AlbumCardLibraryLite` rather than the full AlbumCardLite because
Purchases never needs favorites, the genre overlay, the menu, or
the artist link — it's a read-only grid of bought albums that
either click through (downloadable) or render as unavailable.
The legacy passed a formatted purchase date through the
`releaseDate` slot. AlbumCardLibraryLite doesn't surface a date
field; the purchase date is recoverable from the album detail
page, so the surface loses one secondary cue but stays
functional.
The remaining legacy `AlbumCard.svelte` callers are now only
`HomeView.svelte` and `ForYouTab.svelte`, both Discovery V2
replacement candidates that are slated for deletion once parity
is signed off.
cc
FavoritesView (3 inline grid callsites, one per Discography / EPs &
Singles / Live Albums section per the in-favorites artist-detail
layout) and VirtualizedFavoritesAlbumGrid (one callsite inside the
virtualizer's grid-row branch) both swap to AlbumCardLite. Same
`makeArtistClickHandler` pattern as the other Profile B views.
Same prop drops + renames as before.
cc
Three more Profile B views swap to Discovery V2's AlbumCardLite:
- AwardView: single discography grid; `makeArtistClickHandler`
pattern for the per-album artist link.
- AlbumDetailView: the Related Albums section renders cards for
other albums by the same artist. `onArtistClick` closes over
the page's main `album.artistId` (all related albums share
it). All the relatedAlbum-prefixed handlers route through the
Lite props with the same per-album closures.
- ArtistsByLocationView: three identical-shape grids (Discography
/ EPs & Singles / Live Albums) — slim invocation, no menu
actions, just `onClick` + `onPlay`.
Same prop drops + renames as the earlier migrations.
cc
LabelView (2 callsites: New Releases + Critics' Picks horizontal
rows) and LabelReleasesView (2 callsites: grouped + flat grids)
swap to Discovery V2's AlbumCardLite. Same `makeArtistClickHandler`
pattern as SearchView so the per-album artist id closes correctly
into the zero-arg `onArtistClick`.
Critics' Picks block in LabelView didn't pass the download/
isAlbumFullyDownloaded set originally — kept that gap (it's a
read-only editorial section, no offline-cache UX expected).
Same prop drops as the other Profile B migrations: `size`,
`releaseDate` → parsed `releaseYear`, `onOpenContainingFolder`,
`downloadStateVersion`. Renames: `onAddAlbumToPlaylist` →
`onAddToPlaylist`, `onclick` → `onClick`.
cc
Two callsites in SearchView (Albums tab + Best Match) swap to
Discovery V2's `AlbumCardLite`. Profile B differs from Artist
Detail in that albums DO have a clickable artist label — the Lite
component's `onArtistClick: () => void` takes no args, so a small
`makeArtistClickHandler(artistId)` factory closes over the album's
artist id at iteration time and produces the right zero-arg
handler (or `undefined` when the album lacks an artist id).
Same prop drops as ArtistDetailView: `size`, `searchId`,
`releaseDate` (replaced by parsed `releaseYear`),
`onOpenContainingFolder` (never rendered by legacy menu anyway),
`downloadStateVersion` (Svelte reactivity replaces it). Renames:
`onAddAlbumToPlaylist` → `onAddToPlaylist`, `onclick` → `onClick`.
cc
Six callsites in ArtistDetailView (one per release-type section)
swap from legacy `../AlbumCard.svelte` to
`$lib/discovery-v2/AlbumCardLite.svelte`. AlbumCardLite gained the
`isAlbumFullyDownloaded` flag + `onReDownloadAlbum` callback this
session so the chunked path keeps the offline-cache UX legacy
provided.
Props dropped during migration (legacy artefacts AlbumCardLite
doesn't model and the legacy view didn't really need either):
- `size="large"` — Lite is fixed 220x220, matches the legacy
"large" size already.
- `searchId` — data attribute used by search-result navigation;
not applicable to discography views.
- `releaseDate` — Lite shows `releaseYear` (number) instead of a
formatted date string. Year flows through the artist subtitle
slot the same way the legacy view did (`album.year || ''`),
so the visible info is unchanged.
- `onOpenContainingFolder` — the legacy AlbumMenu declared but
never rendered an item for it; Lite mirrors that.
- `downloadStateVersion` — Lite re-evaluates props natively
through Svelte reactivity; the manual version bump isn't needed.
Renames: `onAddAlbumToPlaylist` → `onAddToPlaylist`,
`onclick` → `onClick` (Svelte 5 convention used across Discovery V2).
cc
Two bugs from the same session:
- The plex_aggregated CTE prefixed `group_key` with `'plex:'`, but
`plex/mod.rs::plex_album_key()` already returns `"plex:<hash>"`.
Result: album.id was `"plex:plex:<hash>"` from the chunked path,
which `handleAlbumClick` couldn't resolve, so opening an album
showed NO TRACKS. Fixed: use `COALESCE(album_key, 'plex:' ||
rating_key)` so populated rows pass through untouched and only
the rating_key fallback gets the prefix added. GROUP BY mirrors
the same expression for consistency.
- On AlbumCardLibraryLite the scrim (`.cover-wrap::after`) and the
overlay container (`.overlay`) were both at `z-index: 2`. Pseudo-
elements paint after all DOM children at the same z-index, so
the scrim ended up ON TOP of the buttons, hiding them on hover.
Re-laid the layering so it's monotonic top-to-bottom:
1 cover img
2 scrim
3 overlay (action buttons)
4 select-checkbox + source-badge-slot
Plus: restored the dark `rgba(0,0,0,0.85)` hover scrim itself,
removed the `::after` accent-border ring + title-colour cue. Per the
2026-05-12 user call ("Hasta quisiera el overlay de regreso") — the
Plex thumbnail downscale (9f417999) made the scrim's opacity
transition cheap again since the underlying paint is now 220x220
not 1000x1000. Buttons go back to opacity 0 → 1 on hover with a
single transition on `.action-buttons`. White-outline circles
(transparent fill) since the scrim provides contrast.
cc
Plex artwork URLs were going directly to `/library/metadata/<id>/thumb/<ts>`
which serves the **full resolution** image — typically 1000x1000 or
larger for album covers. The browser then had to decode the full JPEG
and downscale to the displayed 220x220, blowing through both decode
budget (full-res JPEG decode per visible card) and memory budget
(~4MB raw raster per card × N visible = hundreds of MB of image cache).
For a Plex-dominant library (the user's case), this was a hidden
cost factor on every thumbnail grid surface.
Fix: `buildPlexArtworkUrl` now takes an optional `size` parameter.
When provided, the path is wrapped in `/photo/:/transcode` so the
Plex server downscales server-side and the browser receives ~30KB
JPEGs at the right size instead of 1MB+ originals. When `size` is
omitted, the original full-res URL is returned — preserved for
`getFullArtworkUrl` (AlbumDetailView's big artwork display).
Updated callsites pass `220`:
- LocalLibraryView `getArtworkUrl` (grid + list thumbs)
- MixtapeCollectionDetailView resolve path
- PlaylistDetailView track artwork
Per the 2026-05-12 user call: 220 over 440 — fluidity wins over
HiDPI sharpness on this surface.
cc
Three follow-ups from the 2026-05-12 user call:
- Hover ring on AlbumCardLibraryLite was set up as a
`.cover-wrap::after` pseudo with a transparent → accent border,
but the cover `<img>` lives at `z-index: 1` while the pseudo
inherited `auto` z-index — so the img painted on top of the
ring. `z-index: 2` on the pseudo lifts it above the artwork.
- Scrollbar in `.grid-pool` and `.virtual-container` was styled at
10px wide with a non-standard track / thumb / hover palette,
which read as a one-off vs the global `::-webkit-scrollbar`
rule in app.css. Aligned to the app convention: 8px, transparent
track, `var(--bg-tertiary)` thumb with `border-radius: 9999px`,
`var(--text-muted)` on hover. `scrollbar-width: thin` for the
standard property path.
- `.album-sections.virtualized { height: calc(100vh - 280px) }`
over-extended past the NowPlayingBar (104px) once the scrollbar
was forced always visible — the gutter ran to viewport bottom
instead of ending at the player. Bumped to `100vh - 360px` so
the scroll area ends where the player starts.
cc
User reports neither the grid path nor the legacy virtualizer
(albums list, tracks, folders) show a vertical scrollbar in
LocalLibrary. The global `::-webkit-scrollbar` rule in app.css is
defined but doesn't appear to win against the overlay-scrollbar
behaviour some WebKitGTK builds default to.
Belt-and-suspenders on both scrollers (`.grid-pool` in AlbumGridPool
and `.virtual-container` in VirtualizedAlbumList):
- `overflow-y: scroll` instead of `auto` reserves a permanent
gutter and forces the bar to render even when the content
barely overflows.
- Standard `scrollbar-width` + `scrollbar-color` set, plus the
`::-webkit-scrollbar*` pseudo-element rules with explicit
thumb / track colours pulled from the theme. Different
WebKitGTK configurations honour different sets, so we set
both. 10px width so the bar is clearly visible (vs the 8px
global default).
- Hover state on the thumb shifts to `--text-primary` for
affordance.
Bonus: `albumViewMode` now defaults to `'list'`. Per the same
user call, list mode runs acceptably under SW compositing while
grid still has the perf-cliff problem we're chasing. The toggle
is still in the toolbar so power users can flip it; persistence
is in-session only so the default reasserts on next launch.
Hover indicator on AlbumCardLibraryLite reworked: the previous
inset box-shadow was being painted under the absolutely-positioned
`<img>` so it never showed. Switched to a `.cover-wrap::after`
pseudo-element with a transparent border by default that flips to
`var(--accent-primary)` on `.card:hover`. The pseudo paints after
the image in DOM order, so it stays visible. Title still shifts
to the accent colour as a secondary cue.
cc
Plex cache lives in a separate SQLite database
(`<data_dir>/qbz/plex_cache.db`), which is why `v2_library_get_albums_page`
previously only saw local_tracks and the LocalLibraryView chunked
path had to fall back to the legacy virtualizer when Plex was on.
That fallback meant Plex-dominant users (which is the common case)
got none of the chunked-store / recycling-pool perf work.
Now consolidated server-side via SQL `ATTACH DATABASE` + `UNION ALL`:
Backend (qbz-library):
- `get_albums_metadata_page` accepts `plex_cache_path: Option<&Path>`.
When the file exists it `ATTACH`es the plex DB to the connection,
runs a CTE that aggregates `plex_cache.plex_cache_tracks` by
`album_key` into the same column shape as local_aggregated, and
UNIONs both before the filter/sort/pagination step. DETACHes at
the end so subsequent queries don't see stale attachment.
Plex group_key prefixed with `'plex:'` so it doesn't collide with
local group keys; `'plex'` literal in the `source` column drives
the frontend's SourceBadge mapping.
- Schema differences smoothed via `CAST` in the plex arm:
`duration_ms / 1000 → total_duration` (matches local seconds),
`sampling_rate_hz → sample_rate` (INTEGER → REAL).
- `count_albums_metadata_for_page` mirrors the same UNION so total
is consistent across empty-page edge cases.
Backend (Tauri command):
- `v2_library_get_albums_page` accepts an `include_plex` bool.
When true, resolves the plex cache path the same way
`plex/mod.rs::open_plex_cache_db` does and passes it through.
Frontend:
- Chunked store's `LocalAlbumsQueryParams` carries an `includePlex`
flag, included in the `paramsEqual` check so toggling Plex
visibility invalidates the chunk cache.
- LocalLibraryView drops the `!isPlexLibraryEnabled()` guard from
both the `$effect` that calls `setParams` and the render
condition that picks chunked vs legacy. The new path serves
Plex-only, local-only, and mixed libraries the same way.
cc
Previous attempt used `outline` with positive `outline-offset` so the
ring sat in the gap outside the cover. The leftmost column has no
horizontal slack between the cover edge and the scroll container's
inner edge, so the outline was eaten on the left side of every card
in column 0.
`box-shadow: inset 0 0 0 3px var(--accent-primary)` paints the ring
INSIDE the cover-wrap's box. No layout reflow, no clipping concerns,
no transition — single instant paint on hover entry/exit. Title text
still flips to accent colour for a second cue.
cc
GRID_GAP was 22px and album-grid-row CSS was 22px gap. Discovery V2
uses 32px. Library users with Plex enabled stay on the legacy grid
(per the temporary fallback in 7508c3cd) and saw the smaller gap as
the user-reported "20px separación entre columnas". Aligning to
32px matches Discovery and matches the AlbumGridPool default.
cc
User wanted a hover indicator without bringing back the per-frame
opacity transition that was the scroll-perf bottleneck. CSS `outline`
fits: paints outside the element's box (no layout reflow), no
transition, single paint event on hover entry/exit. Uses
`var(--accent-primary)` solid, no transparency, no animation per
user's "sin animación ni transparencia" follow-up.
Bumped the card cover from 210x210 to 220x220 so Library matches
Discovery V2 `AlbumCardLite` width — with the same 32px gap on both
sides, the column step is now identical and the two views share
one visual rhythm. Updated `VirtualizedAlbumList`'s
`GRID_MIN_CARD_WIDTH` and `GRID_ROW_HEIGHT` so the legacy grid path
(used when grouping or quality filters are on) stays consistent
with the chunked path.
cc
Library was rendering at 22px horizontal / 24px vertical between
cards while Home (DiscoverySection) used 32px in both axes. Reading
the same artist library across the two views felt like two different
densities. Aligning to 32/32.
cc
User feedback: the solid theme-coloured buttons looked "horrible"
against the artwork. Replaced with the same white-outline circles
that Home AlbumCardLite uses, plus a static `box-shadow` under each
button and a small `drop-shadow` on the icon for legibility on light
covers. Cost is a one-time paint per slot rebind, not the per-frame
re-paint the transitioning overlay caused — so the visual contrast
guarantee comes back without restoring the perf regression.
Also bumped buttons further toward the bottom edge (padding-bottom
44px → 12px).
cc
Buttons used to sit center-vertically of the 210x210 cover; user
asked for the Home AlbumCardLite Y position (44px clearance above
the bottom edge). Done with flex `align-items: flex-end` +
`padding-bottom: 44px` rather than `position: absolute; bottom: 44px;
transform: translateX(-50%)` — avoids any transform on the child,
which under SW compositing is one fewer hint for WebKit to maybe
allocate a separate framebuffer over.
cc
Direct attack on the residual scroll-time cost under software
compositing. The user-reported pain ("el mouse over tarda en
reaccionar") is hit-testing: every pointermove during a mouse-wheel
scroll forces WebKit to walk every visible hit target, and with
~50 cards × multiple nested elements per card (cover-wrap, image,
placeholder, overlay, source-badge, buttons) the cascade was visible
as hover delay and scroll lag. Per the 2026-05-12 user call, this is
a palliative — the underlying SW-comp hit-testing cost stays — but
removes the bulk of the hot path.
Two changes:
- `.cover-wrap { pointer-events: none }` opts the decorative
children out of hit-testing. Mouse moves over the cover fall
through to `.card` (which carries `role="button"` and the click
handler) without WebKit needing to consider each of the 5+
nested elements as hit candidates. `.action-buttons` re-enables
pointer-events on itself so the buttons still receive clicks.
- Action buttons are now permanently visible (no `opacity: 0 → 1`
on hover, no transition). Buttons use theme variables —
`var(--bg-primary)` background + `var(--text-primary)` icon —
so contrast holds on any cover artwork without needing the
artwork-palette sampling the legacy AlbumCard did. The primary
play button keeps `var(--accent-primary)` for visual hierarchy.
cc
CPU-mode CSV from the 2026-05-12 measurement window showed mean CPU%
regressing run-over-run since the chunked-store landing:
8f2cd965 (chunked + top/left, no contain) mean 62
a3f1426c (transform on slot) mean 172
9003d93a (+ contain layout paint) mean 176
Theory for the regression: under software compositing every "layer
hint" (`transform`, `contain: paint`, `will-change`) forces WebKit
to allocate a per-slot CPU framebuffer. ~50 framebuffers composited
per frame in CPU dwarfs the layout work the hints were supposed to
skip. Under HW comp those would be GPU layers (cheap); the SW path
isn't symmetric.
Reverts:
- `transform: translate(...)` → inline `top: Npx; left: Mpx`.
- drops `contain: layout paint` from `.grid-slot`.
Keeps:
- drop scrim (f494b1e6) — user explicitly accepted; cheap visual
- skeleton placeholder (9003d93a) — visual-only, no perf cost
- chunked store (8f2cd965) — the actually-confirmed win
Also drops `bufferRows` from 2 to 1, taking pool size from ~63 to
~49 cards. Chunk pre-fetch lead time is still ample (100 albums per
chunk ≈ 14 rows).
cc
Two complementary changes to the chunked LocalLibrary grid:
- `bufferRows = 2` in AlbumGridPool. With ~7 cols + ~5 visible
rows, pool size drops from 77 to 63 cards (-18%). Under software
compositing each rendered card carries per-frame paint and hit-
testing cost, so trimming buffer is a direct win. Chunk pre-
fetch lead time is unchanged in practice — chunks are 100 albums
= ~14 rows, so even bufferRows=1 would give ample lead.
- `renderPlaceholder` snippet wired up in LocalLibraryView. When a
slot maps to an index whose chunk hasn't arrived, the pool now
renders a static skeleton (cover block + 3 text bars) instead
of leaving the slot blank. No shimmer, no animation — shape
alone signals "loading". Matches the loaded card dimensions
so binding the real data later doesn't shift layout.
cc
Per the 2026-05-12 user call ("para localLibrary podría aceptar
quitarle el overlay y solo dejar los botones, que al final son
funcionalidad"), the dark `rgba(0,0,0,0.85)` overlay that filled the
entire 210x210 cover on hover is gone. Each card's hover state now
fades only the 3 action buttons (play + kebab) in over 120ms; the
artwork stays fully visible underneath.
Why this matters under CPU compositing: every pointer-traversal of a
card during a mouse-wheel scroll triggered a paint of the full
overlay area (44K pixels per card alpha-blended over the cover).
Multiplied by ~80 cards in the pool and a continuous pointermove
stream, the paint cost dominated frame time. With the scrim gone,
the per-card hover paint is bounded to ~44x44 px per button
backdrop — a 30x reduction.
Buttons now carry their own `rgba(0,0,0,0.55)` backdrop so they stay
readable on light cover art. Hover boosts that to 0.8. Same visual
hierarchy as before (play primary in white, kebab outlined), just
without the curtain over the artwork.
cc
The transform-only positioning eliminated scroll-tick layout for slot
position updates, but the perf trace still showed 1s+ layout spikes
triggered by pointermove during scroll. Root cause: WebKit's hit-
testing on each pointer event re-evaluates layout across the whole
grid because slot boundaries aren't isolated. `contain: layout paint`
on each slot tells WebKit the slot's layout and paint are independent
of the rest, so a hover-state change (or any internal mutation) in
one slot can no longer cascade to its 79 neighbours.
cc
The Web Inspector perf trace showed scroll-time was dominated by
layout, not script or paint. Single layout records ran 750ms-1.5s
each, triggered by scroll → animation-frame → slot position update.
Each slot's `style="top: Npx; left: Mpx"` mutation invalidates its
own layout box, and with 80 slots repositioned per scroll tick the
cascade became the frame.
`transform: translate(x, y)` on the same absolutely-positioned slot
goes straight to paint/composite and skips layout entirely.
Reported even under software compositing the GPU-layer cost is
negligible compared to the layout work it eliminates.
cc
The chunked grid silently hid every Plex album because
`v2_library_get_albums_page` only queries `local_tracks`. Plex cache
lives in a separate SQLite database (`plex_cache.db`) and the
proper consolidation needs `ATTACH DATABASE` + `UNION ALL` from the
same aggregation, which is the next delta.
Until that lands, gate the chunked path on `!isPlexLibraryEnabled()`
so Plex users keep seeing their full library through the legacy
`VirtualizedAlbumList` (slower under CPU compositing but
functionally complete). Pure-local users still get the chunked
benefit.
cc
Phase A of the Plex-style port: the Albums grid no longer keeps the
full collection (1200+ rows on the user's library) in reactive Svelte
state. Instead, it pulls a single chunk at a time from a new Tauri
command, with the recycling-grid pool firing `requestIndex` for
indices whose data hasn't arrived yet.
Why this matters: Phase B (recycling pool against an in-memory array)
removed mount/unmount churn during scroll but left two costs in
place — every reactive tick still ran the `$derived` filter/sort
pipeline over all 1200 records, and the records themselves still
sat in Svelte's reactivity graph. Under software compositing the
combined cost was still the dominant scroll-time spike.
Server-side pagination eliminates both: sort + filter run in SQL,
only the visible chunk's records are in memory, and the per-tick
$derived work disappears.
Backend:
- `qbz-library/src/models.rs` — new `AlbumsMetadataPage` struct
(`{ albums, total }`) returned by the paginated query.
- `qbz-library/src/database.rs` — `get_albums_metadata_page`:
reuses the existing track-aggregation CTE from
`get_albums_metadata_grouped`, adds a HAVING-style search filter
over the aggregated title/artist (mirroring the legacy
`matchesAlbumSearchFast` semantics), validated allowlist-based
`ORDER BY` for artist/title/year × asc/desc, and
`LIMIT ?/OFFSET ?` on the filtered set. Total comes from
`COUNT(*) OVER ()` in the same query for one round-trip; a
fallback `count_albums_metadata_for_page` covers the empty-page
case where the window function has no row to attach to.
- `src-tauri/src/commands_v2/library.rs` — new
`v2_library_get_albums_page` Tauri command, registered in
`lib.rs`. `include_qobuz_downloads` is read from
`DownloadSettingsStore` to match the legacy
`v2_library_get_albums_metadata` behavior.
Frontend:
- `src/lib/discovery-v2/localAlbumsChunkedStore.svelte.ts` — new
class-backed store with `version` + `total` as `$state`, sparse
`Map<chunkIdx, LocalAlbumLite[]>`, in-flight `Set<chunkIdx>`
dedup, and a generation counter that drops stale responses if
the user switches filters mid-flight. `getAlbum(globalIdx)` is
sync (null for missing chunks); `requestIndex(globalIdx)` fires
the fetch.
- `src/lib/components/views/LocalLibraryView.svelte` — when the
Albums tab is in grid mode AND grouping is off AND no quality
filters are active (the common case), render through
`AlbumGridPool` bound to the chunked store. Other paths
(grouped, filtered, list mode) keep the legacy
`VirtualizedAlbumList` so we don't lose features we haven't
server-side yet. A `$effect` mirrors search/sort/source state
into `setParams` whenever they change; the store no-ops on
unchanged params.
Plex consolidation (other source listed in the audit) deferred to a
follow-up: `plex_cache.db` is a separate SQLite file, so unifying
needs `ATTACH DATABASE` + `UNION ALL` from the same aggregation,
which is a clean delta once the local-only path is verified.
cc
Replaces the keyed `{#each visibleItems as item (album.id)}` pattern
in the Albums grid (Local Library tab, ungrouped) with a Plex-style
recycling pool. Same DOM stays mounted across the entire scroll;
slot bindings update in place.
Why: the legacy keyed-each pattern destroys and remounts card
components every scroll tick. Each remount runs the favorites store
subscription, the cached-image action lifecycle, the quick-menu
floatingMenuStore subscription, and the cover-resolve closure. Under
software compositing (CPU-only WebKitGTK) the mount/unmount churn
dominates scroll cost — confirmed in a 60s CPU window measure with
a library of 1200 albums.
Three changes:
- New `AlbumGridPool.svelte` (Discovery V2): a constant-size pool
of absolutely-positioned slots inside a pre-sized spacer. The
spacer's height is `totalRows * rowStep + bottomPadding` so the
native scrollbar reflects the full collection even when only a
chunk is materialized — same trick Plex uses. Slot count is
derived from `(visibleRows + 2*bufferRows) * cols` and is stable
until container resize, so `{#each Array(maxPool) as _, slotIdx
(slotIdx)}` never destroys a slot during scroll. Data flows via
an abstract `getItem(index)` getter, leaving the door open for
Phase A (backend pagination + chunked store) without refactoring
the pool itself.
- `AlbumCardLibraryLite.svelte`: added an `$effect` that watches
`albumId` and resets the per-slot transient state (`menuOpen`,
`menuAnchor`, `imageError`) when the slot recycles to a new
album. Without this, a menu opened on one album would stay open
after the slot scrolls onto a different album. Also cleared two
earlier issues uncovered while preparing this migration: the
img tag's `src=` was set in parallel with `use:cachedSrc`,
causing the WebView to start a raw-URL fetch in the same tick
the cache action checked locally — duplicate loads on every
scroll. And `content-visibility: auto` on each card was layered
on top of the virtualizer's own slicing, which gave WebKit an
extra intersection check per card per frame with no real
benefit since cards inside a visible row are always in viewport.
- `LocalLibraryView.svelte`: Albums tab grid mode (ungrouped) now
renders through `AlbumGridPool` with a snippet that wires the
same callbacks the old virtualizer used. Grouped grid + list
mode fall back to `VirtualizedAlbumList` for now — grouping
needs header support in the pool, which is a follow-up.
Phase A (backend pagination): unchanged interface. The pool already
takes `totalCount` and `getItem`; swapping in a chunked store with
`getItem` returning `null` for unloaded chunks + `onNeedIndex`
triggering chunk fetches is plumbing only, not a pool rewrite.
cc
First slice of the AlbumCard → Lite migration. Profile A (Local
Library) is the heaviest paint surface in CPU mode because users
routinely scroll thousands of cards. Swapping it onto the Lite paint
strategy is the biggest single CPU-mode win available before Slint.
New component: `src/lib/discovery-v2/AlbumCardLibraryLite.svelte`.
Designed for the LocalLibrary / Purchases / VirtualizedAlbumList
profile, which never surfaces favorites or the genre/date hover.
What it has vs the legacy AlbumCard at `size="large"`:
- 210px fixed width (matches `GRID_MIN_CARD_WIDTH` in
VirtualizedAlbumList so the virtualizer math is unchanged).
- Cero efectos: opacity-only hover overlay, no marquee, no per-
card ResizeObserver, no will-change, no backdrop-filter.
- `content-visibility: auto` so offscreen cards skip paint —
essential for libraries with thousands of albums.
- Disc3 placeholder + imageError fallback + resolveAlbumCover
lookup for user custom covers.
- SourceBadge (bottom-right) and selectable checkbox (top-left).
- Full quality pill (not gated on Hi-Res like the Discovery V2
base AlbumCardLite).
- Right-click context menu mirrors the kebab menu.
AlbumQuickMenu gains the three actions the Library + Search profiles
need but didn't have: `onAddToMixtape` (CassetteTape icon),
`onReDownloadAlbum` (RefreshCw icon), and the
`isAlbumFullyDownloaded` flag that toggles between "Make available
offline" (when false) and "Refresh offline copy" (when true). The
legacy `onOpenContainingFolder` prop is intentionally left
unwired — the original `AlbumMenu` declared it but never rendered
an item, and we mirror that until someone actually wants the
feature.
Migrated callsites:
- `VirtualizedAlbumList.svelte` (grid mode rendering)
- `LocalLibraryView.svelte` (selected-artist albums grid)
Next: AlbumCardSearchLite for Profile B (Search / Awards / Favorites /
VirtualizedFavoritesAlbumGrid), then Purchases as a small follow-up.
cc
User feedback: a long explanatory note plus an interactive toggle
that the runtime silently overrides is confusing. The right shape is
"you literally cannot turn this on without a GPU" — hard-disable the
toggle when HW accel is off, and let the note be a one-liner.
Changes:
- Toggle's `disabled` prop now also depends on `!isHardwareAccelEnabled()`.
Combined with the existing `hideTitleBar || useSystemTitleBar`
guards, the user simply can't flip the toggle in CPU mode. The
visual reads as "available only when conditions are met."
- Toggle's `enabled` prop is also gated so the visual state matches
the effective state, not the stored preference, when HW accel is
off. The stored preference is still preserved on disk and
reactivates the next time HW accel is on.
- i18n note shortened to a single line across all five locales:
"Available only with GPU acceleration." (and equivalents).
cc
User finding (2026-05-12): under SW compositing, toggling the
"Match system window chrome" setting OFF turned an unusable modal
experience into instant popups. The transparent main window forces
GTK to repaint the client-side decoration chrome (rounded corners +
subtle edge + window shadow) on every frame. Under GPU compositing
the cost is imperceptible; under SW compositing it becomes the
dominant frame-budget cost — modals, the splash spinner, and any
other animation visibly drop frames because each tick the WebKit
surface has to be recomposited against the CSD chrome.
Auto-gate the transparent window on hardware acceleration so users
in CPU rendering mode (whether by setting or by env var) don't have
to discover this themselves. While we work toward Slint, this is the
single biggest win available for the SW path.
Implementation:
- `should_use_main_window_transparency()` in src-tauri/src/lib.rs
now reads QBZ_HARDWARE_ACCEL env var (mirroring main.rs) and
falls back to graphics_settings.db.hardware_acceleration. When
HW accel is off, returns false regardless of the user's
match_system_window_chrome preference. The window is built
opaque; the frontend's `windowTransparent` reads false from the
Rust side; the `.match-chrome-transparent` CSS class never
activates. Round corners disappear, opaque rectangle remains.
- The user's chrome preference is preserved in the DB. When HW
accel is re-enabled (next restart with QBZ_HARDWARE_ACCEL unset
or set to 1), the preference reactivates automatically.
- SettingsView shows a `<small class="setting-note">` under the
chrome toggle when `isHardwareAccelEnabled()` is false,
explaining the override. Toggle itself stays interactive so the
user can still set their preference for the next HW-accel-on
session.
- New i18n key `settings.appearance.matchSystemChromeCpuOverride`
in all 5 locales.
cc
Sister script of dev-ultra-restore.sh. Runs `npm run tauri dev` with
the full degraded-rendering env applied so the SW path can be
exercised even when the persisted graphics_settings DB has HW accel
enabled. Useful for testing the modal mount cost, splash spinner
smoothness, and Discovery V2 paint cost under WebKitGTK without GPU.
Sets five env vars:
QBZ_HARDWARE_ACCEL=0 (QBZ's own nuclear opt-out)
WEBKIT_DISABLE_COMPOSITING_MODE=1
WEBKIT_DISABLE_DMABUF_RENDERER=1
GSK_RENDERER=cairo (GTK4 SW renderer)
LIBGL_ALWAYS_SOFTWARE=1 (Mesa llvmpipe)
Flags:
--no-kill skip stale-process cleanup
--print print the env vars and exit (for sourcing)
cc
Mechanical sweep. Each modal swaps the `Modal` import path from
`./Modal.svelte` / `../Modal.svelte` to `$lib/discovery-v2/ModalLite.svelte`.
No other changes — `ModalLite` already exposes the same Props
(`isOpen`, `onClose`, `title?`, `showCloseButton?`, `maxWidth?`,
`children`, `footer?`) with backwards-compatible defaults
(`withScrim={true}`, `footerAlign='start'`), so every call site
renders the same as before.
Modals migrated:
FavoritesEditModal, FolderEditModal, HomeSettingsModal,
LibraryEditModal, LinkResolverModal, LocalLibraryTagEditorModal,
LogsModal, MigrationModal, PlaylistDuplicateConfirmModal,
QconnectPanel, ReportIssueModal, TrackMixModal,
ImmersivePlayer (immersive settings), OfflineCacheManagerView,
FlatpakWelcomeModal, SnapWelcomeModal, UpdateAvailableModal,
UpdateCheckResultModal, UpdateProgressModal, UpdateReminderModal,
WhatsNewModal — and DiscoverySettingsModal moves to the `$lib`
alias for consistency.
Wins from the swap:
- No `backdrop-filter: blur(...)` — the scrim is plain rgba which
paints in one pass even under software compositing.
- No fade-in / slide-up mount animations.
- Dropdown.svelte instances portaled from inside a modal no longer
close the modal when clicked (the previous ModalLite document-
level outside-click listener was the bug; the new overlay-based
backdrop click matches the legacy Modal behavior).
Legacy `src/lib/components/Modal.svelte` is now unreferenced but
kept in tree for one release cycle as a rollback safety net.
cc
Prep for the modal sweep: bring ModalLite to feature parity with
`Modal.svelte` so the migration is a one-line import swap per modal.
Three changes:
1. New `withScrim` prop (default true) that brings back the dim
`rgba(0,0,0,0.7)` background under the modal. The earlier
overlay-less variant existed because the *blur* on the scrim was
the perf killer on software compositing — but the dim itself is
just a single rect paint. Now that HW accel is on by default
(ADR-004) the rect is cheap. Pass `withScrim={false}` to keep the
overlay-less floating variant.
2. New `footerAlign` prop (`'start'` | `'end'`, default `'start'`).
Legacy `Modal.svelte` left-aligns footer content; the original
ModalLite right-aligned it. Defaulting to `'start'` so existing
modals migrate without visual shift; opt into `'end'` for the
modern Cancel | Save right-aligned convention.
3. Backdrop click handled via the overlay div (`e.target ===
e.currentTarget`) instead of a `document.mousedown` listener. The
document listener closed the modal when clicking any portaled
descendant — including `Dropdown.svelte` instances opened from
inside the modal, which is a known interaction pattern (see
memory: feedback_use_shared_modal_component). The overlay
approach restores legacy behavior without re-introducing the
blur perf cost.
Cero animations on mount kept (no fade-in, no slide-up) — that part
of the perf philosophy stays.
cc
Three visual fixes from the latest review:
1. SpotlightLite rewritten faithfully to the legacy ForYouTab structure:
a hero (portrait + ARTIST label + name + play-top-tracks + go-to-artist
circle buttons) plus a single paginated row mixing a TOP TRACKS radio
card, a RADIO card, the artist's Qobuz playlists (as PLAYLIST-labelled
radio cards) and the artist's albums (AlbumCardLite). Dropped the
earlier invented sub-blocks (separate Top Tracks list, separate
Albums grid, separate Playlists row). Internal `handleSpotlightTopTracks`
and `handleSpotlightRadio` ported from the legacy ForYouTab so the
play + radio buttons work out of the box from DiscoveryView.
RadioCardLite gains an optional `label` prop (default "RADIO") so the
same stereo-component visual can render the TOP TRACKS and PLAYLIST
variants the spotlight needs.
2. ArtistTileLite gains a subtle hover (background tint, 120ms ease) so
the cursor reads as interactive on Your Top Artists. With an `onFollow`
callback it also renders an "Artist" sublabel and a Follow chip with a
UserPlus icon (or LoaderCircle while pending). DiscoveryView passes
`onFollow` only for the Artists-to-Follow section via a new
`artistTileFollowable` snippet; Top Artists keep the bare tile. Follow
uses `toggleArtistFavorite` and strips the artist from the suggestion
list on success, matching legacy behavior.
3. Replaced the "Discovery V2 — under construction" placeholder with a
loading skeleton: three placeholder sections (title bar + a row of
220x220 card silhouettes) shown until the three main fetches
(release-watch, discover-index, home-resolved) resolve. Cero efectos
(no shimmer, no animation) — the shape alone signals "loading".
Removed `discovery.comingSoon` from all 5 locale files; the truly
empty case now falls through to `empty.noContent`.
cc
Three issues from the latest visual review:
1. Spotlight was missing the RADIO card that anchors the Qobuz layout.
Added `RadioCardLite.svelte` — a 220×220 stereo-component-styled
card with vertical speaker-bar pseudo-elements, the seed's artwork
centered as the "display", a RADIO wordmark, and a background color
pulled from the artwork's dominant palette (artworkPalette). Used
in SpotlightLite as the first item of the albums row alongside the
six release tiles, and as the renderItem for the new "Radio
Stations" section in DiscoveryView (seeded from the union of
recentlyPlayedAlbums + favoriteAlbums, deduped, sliced to 12).
2. Three untranslated keys in SpotlightLite. The keys lived in
different namespaces than I'd guessed:
- `home.popularTracks` → `artist.popularTracks`
- `home.albums` → `artist.albums`
- `home.radio.startArtistRadio` → `actions.radio.startArtistRadio`
Added one new key `discovery.qobuzRadioStation` for the radio card
subtitle, across all 5 locales.
3. QobuzMixesRow tiles were stretched by `grid auto-fit minmax(200px,
1fr)` so on wide windows they ran 280-320px each — visually outsizing
every other card and making the section feel cartoonish. Switched
to `display: flex; flex-wrap: wrap; gap: 32px` with fixed 220px
tiles. Same scale as AlbumCardLite now.
cc
Closes parity with the legacy ForYouTab by adding the 7 sections it
had that aren't shared with Home / Editor's Picks:
- qobuzMixes (Home + For You) — 4-tile entry point to DailyQ /
WeeklyQ / FavQ / TopQ via existing onNavigate* callbacks.
Implemented as `QobuzMixesRow.svelte` (CSS grid + gradient
artwork tiles, no fetch).
- rediscoverLibrary (For You only) — albums favorited but unplayed
recently. Wraps `v2_reco_get_forgotten_favorites`.
- artistsToFollow (For You only) — similar artists derived from
user's top-3 seeds, deduped against already-followed. Wraps
`v2_get_cached_favorite_artists` + `v2_get_similar_artists` × 3.
- similarAlbums (For You only) — Qobuz suggestions seeded by a
random recent album. Dynamic title "Similar to {seed}".
- essentialsByGenre (For You only) — `ideal-discography` featured
albums for the user's top genre. Dynamic title "Essentials in
{genre}". Wraps `v2_reco_get_top_genres` + `v2_get_featured_albums`.
- artistSpotlight (For You only) — featured-artist hero with
portrait + top 5 tracks + 6 albums + playlists. Wraps
`v2_get_artist_page`. Implemented as `SpotlightLite.svelte`,
minimal but representative: no radio-card extraction, no
spotlight color theming (deferred — both were cosmetic in legacy).
Type system: 7 new IDs added to `DiscoverySectionId`. `DEFAULT_PREFS`
now reflects per-tab availability (the customize modal naturally hides
sections not present in the active tab's prefs list):
- home: 12 universal + qobuzMixes
- editorPicks: 7 editorial-only (no personalized ever)
- forYou: 12 universal + qobuzMixes + 6 exclusives + spotlight
Data layer: 5 new fetch functions in `data.ts` (`fetchRediscoverLibrary`,
`fetchArtistsToFollow`, `fetchSimilarAlbums`, `fetchEssentialsByGenre`,
`fetchArtistSpotlight`) and 3 new typed shapes (`SimilarAlbumsSection`,
`EssentialsSection`, `SpotlightSection`). All fired in parallel after
home-resolved completes (the seeds — top artists, recent albums — are
needed first). All errors caught and degraded to empty results so a
broken backend response doesn't break the whole view.
i18n: adds `discovery.{similarAlbums, similarTo, rediscoverLibrary,
essentialsByGenre, essentialsIn, artistsToFollow, artistSpotlight}`
across the five locales.
radioStations is registered as an ID but not yet rendered — the legacy
implementation derived radio cards from recent + favorite albums, will
land in a follow-up commit.
cc
Brings the three Discovery tabs to feature parity with the legacy
HomeView by extending `sectionPrefs.ts` to keep an independent prefs
list per tab. Each tab draws from the same already-fetched V2 data
(release-watch + discover-index + home-resolved); the only difference
is which sections are enabled and in what order.
Defaults per tab:
- Home: balanced mix (newReleases, pressAwards, qobuzPlaylists,
recentlyPlayedAlbums, continueListening, idealDiscography,
mostStreamed).
- Editor's Picks: editorial-only (newReleases, editorPicks =
Album-of-the-Week, qobuzissimes, pressAwards, mostStreamed,
idealDiscography, qobuzPlaylists). Personalized sections off.
- For You: personalized-only (releaseWatch, recentlyPlayedAlbums,
continueListening, topArtists, favoriteAlbums). Editorial off.
Every section ID exists in every tab's list — toggling moves it
between enabled/disabled without losing its position. Users customize
each tab independently via the gear icon, which now passes the
currently-active tab to the settings modal.
Storage shape changes from a flat `DiscoverySectionPref[]` to a
`DiscoverySectionPrefsByTab` object keyed by tab. `migrate()` handles
three forms gracefully: legacy flat-array prefs become the new `home`
slot with defaults for the other two tabs; new-shape blobs with
partial data get defaults filled in; anything malformed falls back to
all-defaults.
DiscoveryView drops the `homeTab !== 'home'` placeholder. The
existing section-rendering loop already keyed by the prefs list, so
the per-tab change is just `$sectionPrefs` → `$sectionPrefs[homeTab]`.
cc
Adds a tiny frontend runtime state module (`src/lib/runtime/graphicsState.ts`)
that fetches `v2_get_graphics_startup_status` once at app boot and caches it
for sync access from hot paths. Bootstrapped from `src/lib/app/bootstrap.ts`
fire-and-forget; falls back to "HW accel ON" defaults if the invoke errors
out.
Re-introduces the WebKitGTK 2.50+ texture-eviction workaround in
`src/lib/actions/cachedImage.ts` (`will-change: transform; transform:
translateZ(0)` on every artwork `<img>`), now gated by
`isHardwareAccelEnabled()`. Under HW accel the hack is a cheap GPU layer
hint that prevents the placeholder flash the comment in the file
describes. Under software compositing each forced layer becomes a CPU
framebuffer and the cure is worse than the disease (confirmed during the
2026-05-12 perf session — removing this hack dropped scroll cost
substantially when HW accel was off).
Documents the policy in `qbz-nix-docs/qbz-adr/ADR-004-hw-accel-default-on.md`
together with the schema default flip from the previous commit:
- HW accel is ON by default for new installs
- Software compositing is an explicitly degraded mode; we won't invest
more engineering hours optimizing for it
- The auto-detect mitigation logic in `main.rs` continues to force
compositing off for known-broken configs (NVIDIA-Wayland-only)
- The Slint migration is the strategic answer to the software-
compositing performance ceiling, not more WebKitGTK tuning
- Conditional optimizations (like this flicker fix) gate on
`isHardwareAccelEnabled()` and apply only where they're a net win
cc
The earlier default of `hardware_acceleration = 0` was set "for maximum
compatibility" but in practice it shipped a severely degraded experience
to every user with a working GPU. Under WebKitGTK software compositing
(what hardware_acceleration=0 forces via WEBKIT_DISABLE_COMPOSITING_MODE
in main.rs:477) mounting any new component takes multiple seconds on
4K / tiled windows — confirmed during the 2026-05-12 perf session where
the same modal that took 2-3s to open under SW comp opened instantly
with HW accel on.
The mitigation logic in `main.rs:482-496` already handles known-broken
configs (NVIDIA-only Wayland forces compositing off; AMD/Intel/hybrid
configs run with compositing enabled). With HW accel = ON as the new
default, that mitigation logic actually runs — previously the nuclear
"off everything" branch at `main.rs:468` was the de-facto path for
every user who hadn't explicitly opted in.
Schema change is non-destructive: `INSERT OR IGNORE` only seeds the
row on first run, so existing users who already have a row keep their
preference. Only fresh installs (and the test environment after
`DELETE FROM graphics_settings`) pick up the new default.
cc
Two perf agents independently identified the same dominant cost behind
the 2-3s modal-open latency on non-maximized windows: 8 modals declared
at the top of `+page.svelte` (PlaylistModal, PlaylistImportModal,
FolderEditModal, AboutModal, QualityFallbackModal, KeyboardShortcuts,
KeybindingsSettings, LinkResolverModal) were always mounted, with only
their inner templates gated by `{#if isOpen}`. Their `<script>` blocks
ran for the lifetime of the app and their reactive declarations
re-evaluated on every parent tick — and the parent ticks at ~4Hz during
playback via `playerStore.notifyListeners()`.
PlaylistModal alone is 1,420 lines / 18 reactive declarations. Sum
across 8 modals × ~4Hz × the +page.svelte reactive graph (188+ $state)
explains why mounting any *new* component (Discovery's customize modal,
the back-to-top button) raced with reactive churn and landed seconds
after the user clicked.
Fix: wrap each modal at the mount site in `{#if isOpen}`. The inner
component instance now only exists while the modal is open. Two-line
change per modal, zero API change. The `isOpen` prop on each modal is
left in place so the modal's internal close logic (focus mgmt, ARIA,
close animation) keeps working unchanged.
Also drops the 64px Gaussian box-shadow on `ModalLite.svelte` that I'd
added one commit prior. Under software compositing the blur radius
re-rasterizes whenever anything beneath repaints, defeating the whole
point of removing the overlay. Replaced with a flat composite shadow
(1px edge + 16px small-offset) — reads as "floating" without the
per-frame Gaussian cost.
Documents the pattern in `qbz-nix-docs/qbz-adr/ADR-003-conditional-
mount-pattern.md` so the same anti-pattern can be swept across the rest
of the codebase systematically. The ADR identifies search heuristic
(`<X isOpen={...} />` outside `{#if}`), the cost mechanism, and the
roadmap touchpoints (ModalRouter extraction from +page.svelte) where
the rule should be applied during decomposition.
cc
The shared `Modal.svelte` uses a slide-up entrance with
`transform: translateY(20px) scale(0.98)` animated to identity over
200ms. Under WebKitGTK's software compositing this requires per-frame
pixel interpolation on a ~480x600 surface; the user reported visibly
slower modal opens at smaller / tiled window sizes vs maximize (where
HW compositing kicks in and absorbs the cost).
`src/lib/discovery-v2/ModalLite.svelte` is a drop-in clean-room
replacement with the same Props surface (`isOpen / onClose / title /
maxWidth / children / footer`). Differences:
- Opacity-only fade-in (120ms). No transform on the modal element.
- Single keyframe on the overlay; the modal itself doesn't animate.
- No `backdrop-filter` (matches the cleanup we applied to the shared
Modal earlier — kept consistent here for paint cost).
- Same portal-to-body, ESC, outside-click, max-height behavior.
Lives inside `discovery-v2/` rather than `lib/components/` so it does
not affect the 30+ existing modals using the shared component. This
aligns with the user's preference to keep Discovery V2 as a clean-room
silo while the monolith decomposition progresses.
`DiscoverySettingsModal.svelte` swaps its import from the shared Modal
to ModalLite. No other modal consumers need to change.
cc
Albums frequently carry multiple awards in the same `awards[]` array
(Album of the Week + a press accolade, for example). The previous logic
took the last entry, which meant the New Releases section's flagship
weekly pick was often hidden behind whatever press award Qobuz returned
last in the array.
`pickAlbumRibbon` now picks by explicit priority:
1. Album of the Week (id '151') — Qobuz's flagship editorial badge
2. Qobuzissime (id '88') — secondary editorial badge
3. Press (last award in array) — third-party accolades
cc
Aligns the AlbumQuickMenu item copy with how qbz product talks about
queue and offline:
- "Play Later" → "Add to queue" (callback `onPlayLater` kept for
backwards compat with parent handlers; only the user-facing label
changes via the existing `actions.addToQueue` i18n key).
- "Download" → "Make available offline" using the existing
`actions.makeAvailableOffline` key (already present in all five
locales). Icon swaps from lucide `Download` to `CloudOff` — the
cloud-with-slash glyph reads as "stored locally, not pulled from
cloud" which matches the offline-cache mental model better than a
down-arrow tray.
Order shift: queue actions first because they're the most-used menu
entries during browsing — "Add to queue" (default add-at-end) at
position 1, "Play Next" at 2. The rest of the menu order is unchanged.
The `actions.playLater` / `actions.download` keys I'd added earlier
remain in the locale files but are orphaned now; left in place rather
than purging them in this commit to keep the diff narrow.
cc
Brings the playlist cards in line with the AlbumCardLite hover pattern,
freshly implemented on top of the same primitives instead of porting
the legacy `QobuzPlaylistCard.svelte` (which carried the
backdrop-filter / hover-marquee / overlay-blur problems we just shed).
PlaylistCardLite gains:
- Dark scrim on hover (`rgba(0,0,0,0.6)` via `::after`, opacity-only
transition — same as AlbumCardLite).
- Centered actions with slide-up entrance: primary play (white-fill
44px) + kebab (36px outline). No heart for V1 — there's no
frontend playlist-favorites store yet; copy-to-library lives in
the menu instead.
- Inline AlbumQuickMenu (renamed in intent — now generic across
media kinds). Playlist actions wired: Play Next, Play Later,
Copy to Library, Share on Qobuz.
AlbumQuickMenu picks up `onCopyToLibrary?: () => void`. Renders only
when the callback is provided so album cards (which don't pass it)
omit the entry cleanly. Uses the existing `playlist.copyToLibrary`
i18n key.
DiscoveryView extends its Props consumption with the four playlist
callbacks the parent has been passing through unread:
onPlaylistPlayNext, onPlaylistPlayLater, onPlaylistCopyToLibrary,
onPlaylistShareQobuz. Forwarded to the playlistCard snippet.
cc
Three coordinated overlay tweaks so the three top-corner annotations
stop fighting for the same space:
- Ribbon (press / albumOfTheWeek / qobuzissime): top:8px → bottom:8px.
The top of every cover is now uncovered — that's typically where
cover-art focal points (titles, faces, hero artwork) live.
- Meta (genre + year): top-right → top-left. Text-align right → left,
flex-end → flex-start. Now lives in the corner the ribbon just
vacated. Font sizes (14px / 13px) and width allowance (70%)
unchanged.
- Action buttons: bottom:20px → bottom:44px. Lifted by ~24px so they
clear the bottom-anchored ribbon. Slide-up entrance and opacity
transition unchanged.
Net layout on hover:
┌───────────────────────┐
│ [GENRE/YEAR] │ ← top-left
│ │
│ [▶] [♡] [⋯] │ ← bottom-center-ish, above ribbon
│ [RIBBON] │ ← bottom-left
└───────────────────────┘
cc
Three coherent visual changes:
1. DiscoveryGridSection adapts column count to container width via
ResizeObserver: `columns = clamp(floor(width / cellMinWidth),
minColumns, maxColumns)` with defaults 3/5 and cellMinWidth 280.
`rows` stays fixed at 3. Yields 9 / 12 / 15 items per page across
narrow / medium / wide viewports. Top Albums slice bumps 12 → 15
so the widest layout fills completely.
2. Hi-Res badge gates on `isHiRes`. CD-quality albums leave the slot
empty — matches the Qobuz web player which only flags Hi-Res
content. The badge itself swaps to a yellow gradient pill
(#f5c042 → #d49511) with the literal "Hi-Res" label; the full
quality string ("24bit/96kHz") stays on the title attribute. New
`isHiRes?: boolean` field on DiscoveryAlbumCard, derived in all
three data mappers (DiscoverAlbum, QobuzAlbum, RecoAlbumCardMeta
via the "CD Quality" string check on the home-resolved variant).
3. Hover meta (genre + year) reposition: `top: 12px → 44px` so it
never overlaps award ribbons that sit at the top edge. Font size
bumps `12px → 14px` for genre, `11px → 13px` for year (the
requested +2pt). Width allowance `60% → 70%`. Same right-aligned
anchor and same text-shadow.
cc