Replaces the inline grid (and the prior horizontal-scroll-carousel
attempt) with chevron-paginated sections that mount only the current
page's cards. Each section is a generic component over T with two key
props: `items: T[]` and `renderItem: Snippet<[T]>`. Internal state:
- `itemsPerPage` = `floor((containerWidth + gap) / (cardWidth + gap))`
computed on mount + on resize via ResizeObserver. Responsive: 4
cards at 1280px, ~12 at 4K maximize.
- `page` advances/retreats on chevron click. Maximum is clamped to
`ceil(items.length / itemsPerPage) - 1`.
- The visible slice is `items.slice(page * itemsPerPage, …)`. Only
those cards are mounted; off-page cards never enter the DOM.
The `.row` is a plain `display: flex` — no `overflow-x: auto`, no
transform, no scroll handler, no extra backing surface under software
compositing. That was the structural issue with both the original
HorizontalScrollRow.svelte and the Cider-pattern attempt: N nested
scroll containers each materializing their own paint surface in the
vertical scroll path. This approach has zero such containers.
DiscoveryView extracts per-card-type snippets (`albumCard`, `trackCard`,
`playlistCard`, `artistTile`) and passes each section the appropriate
data array + snippet. ArtistTile passes `cardWidth: 140` so the per-page
math matches the circular tile's actual width.
cc
Replaces the inline CSS grid with a native horizontal scroller and
chevron-paged navigation, mirroring the pattern Cider uses for Apple
Music shelves:
- `overflow-x: auto` native scroll (no transform-based carousel, no
virtualization).
- Two reactive booleans (`canScrollLeft`/`canScrollRight`) recomputed
on every scroll + on resize via ResizeObserver. They drive chevron
disabled state AND the `.scrollable-left`/`.scrollable-right`
classes on the scroller.
- Those classes apply `mask-image: linear-gradient(…)` fade gradients
at the active edges. Pure CSS mask — GPU-implementable on accelerated
backends, low-cost CPU rasterize otherwise. Replaces "I need a hover
overlay to discover more content" with a static visual hint.
- Page-step scroll: chevrons advance N items where N = items per
viewport (computed from cardWidth / clientWidth). Smooth-scrolled
via `scrollBy({behavior: 'smooth'})`.
- Scrollbar hidden — chevrons + edge fades carry the affordance.
`see-all` link, when present, sits alongside the chevrons in the header
actions area. Section header is `display: flex` with `justify-content:
space-between` (title left, actions right).
The cards inside still carry `content-visibility: auto`, which means
off-viewport cards in the same scroller skip their render — the
horizontal-scroll perf cost is bounded to what's currently visible.
cc
TrackCardLite already accepted an `isPlaying` prop and styled `.title`
with `--accent-primary` when set. DiscoveryView now passes it for the
Continue Listening section by comparing each track's id against the
parent's `activeTrackId` (combined with `isPlaybackActive`).
Album-level sections don't get the highlight yet — the cards carry an
albumId, but `activeTrackId` is at the track level and we'd need to
resolve the currently-playing track's parent albumId from the parent
playback context. That wires once the parent surfaces a
`currentAlbumId` derivation.
cc
Mounts the existing `GenreFilterButton` (with its own popup and store
subscription) into the toolbar's right-side slot. On filter change,
re-runs the discover-index fetch with the new genre IDs — the V2
endpoint already accepts `genreIds` and the response containers come
back filtered.
Release-watch and home-resolved are not re-fetched on genre change:
the former is personalized (followed-artists feed, genre-agnostic) and
the latter draws from local reco-store state that the user's local
play history doesn't get retroactively filtered by browse-time genre.
Matches HomeView.svelte's existing behavior (only the discover-index
sections re-filter).
Extracted the on-mount load into `loadAll()` so the genre-change handler
can call it directly without duplicating the parallel-fetch logic.
cc
The toolbar exposes three tabs (Home / Editor's Picks / For You) but V1
of Discovery only populates the Home tab. Clicks on the other two were
toggling `homeTab` but the scroll-area kept rendering the Home sections
— misleading UI.
This commit gates the section block: when `homeTab !== 'home'`, render
only the "under construction" placeholder. Editor Picks and For You tab
content will land in follow-up commits with their own data fetches
(`v2_get_discover_index` already brings what Editor Picks needs; For You
uses the dailyq/weeklyq/topq/favq recommendation set).
cc
Adds three new card components and five more sections, completing the
Home tab's section coverage at V1 minimum:
New cards:
- TrackCardLite: identical layout to AlbumCardLite, separate file so
future track-only metadata lands here without bleeding into album
cards.
- PlaylistCardLite: same 180px square, ListMusic placeholder when no
cover, single title line (no description, owner, track count).
- ArtistTileLite: circular 140px avatar + name. No play count.
data.ts additions:
- `fetchHomeResolved` — wraps `v2_reco_get_home_resolved` with explicit
per-section limit args (Rust signature uses limitRecentAlbums,
limitContinueTracks, limitTopArtists, limitFavorites). Maps the
backend-resolved minimal shapes (AlbumCardMeta, TrackDisplayMeta,
ArtistCardMeta) into Discovery card types. Single round-trip yields
four lists.
- `DiscoverIndexSections.playlists` — the discover-index response
already carries a playlists container; we now extract it instead of
making a second call to `v2_get_discover_playlists`.
DiscoveryView fires three parallel fetches via Promise.all (release-watch
+ discover-index + home-resolved) on mount and renders eleven conditional
sections in display order: recently played, continue listening, release
watch, new releases, most streamed, editor picks, qobuzissimes, press
awards, Qobuz playlists, top artists, favorite albums.
cc
Renames `fetchNewReleases` to `fetchReleaseWatch` to match what the V2
command actually returns (followed-artists release radar, not editorial
new releases). Adds `fetchDiscoverIndex` which calls `v2_get_discover_index`
once and splits the response's five containers into per-section card
arrays. One round-trip yields:
- newReleases (editorial)
- pressAwards
- mostStreamed
- qobuzissimes
- editorPicks (album_of_the_week)
DiscoveryView mounts release-watch and discover-index in parallel via
Promise.all, then renders six sections in display order (release-watch,
new releases, most streamed, editor picks, qobuzissimes, press awards).
Each section is conditional on having items, so empty containers don't
render an empty title row. The `discoverAlbumToCard` mapper drops every
field except albumId/title/artist/artistId/artwork — V1 doesn't render
quality, awards, parental_warning, etc., so we don't carry them around.
An inline `albumGrid` snippet keeps the per-section markup DRY without
introducing another wrapper component.
cc
Adds `src/lib/discovery-v2/data.ts` with `fetchNewReleases(limit)`, a pure
wrapper around `v2_get_release_watch`. The V2 command already returns full
`SearchResultsPage<Album>` payloads in a single round-trip — the original
HomeView did N+1 calls (release-watch followed by `v2_get_album` per id),
which Discovery sidesteps. The mapping reduces each QobuzAlbum to the
minimal shape AlbumCardLite needs (id, title, artist name + id, artwork
URL via getQobuzImageForSize 'small'), dropping fields V1 doesn't render
(quality, bitDepth, samplingRate, awards, parental_warning, etc).
Wires the section into DiscoveryView: on mount, fetch 8 new releases and
render via DiscoverySection + AlbumCardLite. Click on a card calls
onAlbumClick, the permanent play button calls onAlbumPlay, and the artist
text link calls onArtistClick — all forwarded from the props +page.svelte
already passes through. "Ver todo" routes to the existing release-watch
full grid view via onNavigateNewReleases.
cc
Establishes the skeleton for a Discovery rebuild under "cero efectos"
constraint after a perf debug session confirmed the original home view's
vertical scroll cost was structural and accumulative, not a single
offender. Spec: qbz-nix-docs/specs/2026-05-11-discovery-v2-clean-room.md.
Adds:
- src/lib/discovery-v2/AlbumCardLite.svelte: 5-element card (cover,
title, artist, permanent play button). Cero `transition`, cero
`:hover` paint, cero `will-change`, cero `backdrop-filter`.
- src/lib/discovery-v2/DiscoverySection.svelte: titled inline CSS grid
of cards + "See all" link. No `overflow-x: auto` carousel — sections
wrap onto rows naturally.
- src/lib/discovery-v2/DiscoveryView.svelte: top-level. Same Props as
`HomeView.svelte` for drop-in mount swap. Toolbar with three tab
buttons (Home / Editor's Picks / For You) on the left and a slot
for the genre selector on the right — fixed 56px height, static
(no `position: sticky`), part of the main paint pass.
- i18n keys `discovery.{comingSoon,seeAll}` across en/es/de/fr/pt.
Swaps the mount in `src/routes/+page.svelte:5826` from `<HomeView>` to
`<DiscoveryView>`. `HomeView.svelte` stays in the tree as rollback. Real
sections will be wired incrementally in follow-up commits, each measured
against the perf baseline established by the scripts/measure-cpu-window.sh
tooling.
cc
Samples /proc/PID/stat directly (no pidstat dependency) for the qbz binary
and any WebKit child processes. Outputs mean/p50/p95/max per process plus
appends a CSV row to /tmp/qbz-cpu-measurements.csv for cross-run comparison.
Usage:
./scripts/measure-cpu-window.sh <label> [duration_seconds]
Built during the 2026-05-11 perf debug session to replace seat-of-the-pants
"does it feel faster?" with comparable numbers across changes.
cc
The bar declared `background: var(--bg-secondary); backdrop-filter: blur(20px)`.
`--bg-secondary` is opaque across all themes, so the blur was computed for
nothing — WebKit applied the per-frame blur computation to pixels under the
bar that the opaque bg then completely overdrew. Pure waste under software
compositing.
cc
The action was applying `will-change: transform` + `transform: translateZ(0)`
to every cached <img> as a workaround for WebKitGTK 2.50+ texture eviction
flicker on hover/scroll.
Under hardware-accelerated compositing this is cheap — each image is a GPU
texture handle. Under software compositing (QBZ's default), each forced
layer materializes its own CPU framebuffer. With 100+ AlbumCards in the
home view, that's 100+ extra buffers the compositor must manage every
frame, dominating the structural paint cost of scroll.
User confirmed in the 2026-05-11 perf session that the flicker the hack
was meant to prevent does not reappear after removal on his setup. If it
shows up again on other reports, the re-introduction should be guarded by
the hardware-acceleration setting (apply only when GPU is on).
cc
The `.artwork-bg` layer rendered the playlist cover scaled-up + heavily
blurred behind the contain-fitted main image. Under software compositing,
filter:blur(30px) saturate(1.5) on N playlist cards × visible row = paint
cost proportional to card surface, paid every frame the row repaints
during vertical scroll.
For square playlist art (the common case) `.artwork-main object-fit: cover`
covers the whole container — the blurred bg was invisible anyway. For
non-square art the original cover shows through at the letterbox edges
without the blur. Acceptable trade.
cc
Removed under software compositing the changes that proved either wasteful
or hot during scroll:
- `:hover` marquee animations on title/artist (infinite keyframes that kept
WebKit's paint loop alive while hovered, especially across cards passed
during scroll)
- `.action-overlay` `backdrop-filter: blur(12px)` (computed every frame even
at opacity 0; the bumped opaque background covers visually)
- `.editorial-ribbon` `backdrop-filter: blur(4px)` (multiplied by N cards
with badges; minor individually, significant in aggregate)
Added `content-visibility: auto` + `contain-intrinsic-size: 230px 320px` on
`.album-card`. Confirmed in the 2026-05-11 perf session to substantially
help horizontal scroll under software compositing by skipping offscreen
card render entirely.
cc
Wire isTrackRemovedFromQobuz into TrackRow consumers (VirtualizedTrackList,
AlbumDetail, DynamicSuggest, TopQ, WeeklySuggest). Tracks marked unstreamable
by the API or hit by a TrackUnavailable error this session now render dimmed,
non-draggable, non-clickable, with the i18n tooltip player.trackUnavailable.
Reactivity for the {@const} reads uses a $state counter bumped from the
unavailable-tracks store subscription so the dim state updates live without
forcing the whole list to re-render.
cc
The error strings flow up through stream_error and end up in user-
visible logs. Replace the internal-shaped messages ("opened at XHz
while device nominal rate is YHz") with text that names the cause,
the remediation, and the reason the fallback path is refused.
CPAL caches device configs and can briefly return a stale sample rate
right after CoreAudio changes the device nominal rate. Before this
change, opening at the stale rate failed the post-open validation and
returned Err, leaving the user with no audio on the first track after
a rate change.
Drop the bad sink, re-resolve the device, re-query nominal rate and
try once more with a 50ms backoff. Two attempts in total. Turns the
racy window into transparent recovery.
Extract the boolean decision out of evaluate_stream_recreate into a
pure function so the truth table can be tested without faking a
MixerDeviceSink or AudioSettings mutex. Eight cases cover the matrix:
no stream, unchanged format, format change with and without each
bit-perfect backend, and the CoreAudio shared-mode rate-mismatch path.
The rate-mismatch detection block was duplicated between the Play and
Stream handlers, and each handler acquired thread_settings.lock() three
times in a row. Extract into one helper that reads settings once and
returns every flag the recreate decision needs.
Also drops a dead else-branch that logged "Creating initial audio
stream" — unreachable inside if stream_opt.is_some() — and the
redundant info-log that re-announced the rate-mismatch reason at
recreate time (the warn-level log inside the helper already says
"Recreating stream to avoid wrong-speed playback").
The field tracks the decoded source format, not the output stream's
hardware rate. The names are now explicit so future readers don't
conflate the two — on macOS shared mode these can legitimately differ
since Rodio resamples internally.
Hotfix release. Reverts titlebar-mode-selector and PR #406 (broken
window chrome on Linux); preserves PR #407 (sticky home header) and
adds PR #408 (macOS native icon and tray variants by @Vudgekek).
cc
v1.2.12 hotfix: revert the titlebar-mode-selector merge and PR #406
which together caused all titlebar modes to render incorrectly in the
production binary (empty top space, gap above now-playing bar). The
v1.2.10 model (use_system_titlebar + hide_title_bar booleans) is
restored; Plasma mode is dropped pending a less brittle redesign.
PR #407 (sticky home header fix) is preserved.
Reverts merge f7623154 which built macOS support on top of the variant
rendering infrastructure introduced by titlebar-mode-selector. With the
variant system gone (previous revert), PR #406's changes (class:macos,
stripped strip with traffic-light reservation, macOS-aware drag bands)
reference dead architecture.
Most of PR #406 was already removed by the previous revert via file
checkouts; this commit cleans up the residual macOS-specific cosmetics
in Sidebar.svelte and +page.svelte (padding-top 36→32, drag-region
height 32→28, macos main-content padding restored to generic rule).
PR #407 (sticky home header fix, ac627d43) is preserved — independent
from titlebar work, only touches HomeView.svelte.
cc
Reverts merge 1be46f1a which introduced the 4-mode titlebar enum
(qbz/system/plasma/hidden), variant-aware rendering (full/stripped),
GDK_BACKEND=x11 coercion for Plasma, and per-mode mounting in +page.
The "stripped strip mounted alongside Plasma SSD" architecture caused
all titlebar modes to render incorrectly in the production binary —
empty space at top, separation gap above the now-playing bar, controls
visible without a backing strip. Reverting restores v1.2.10 behavior:
two booleans (use_system_titlebar + hide_title_bar), no variants, no
GDK coercion. Plasma support is dropped pending a less brittle redesign.
Conflicts in SettingsView.svelte resolved by taking the pre-merge side
for the four titlebar UI blocks; non-titlebar additions in this cycle
(Discord RPC settings, reserve-DAC, offline cache nav) preserved.
cc
Update qbzVersion + srcHash + npmHash for the v1.2.11 tag (commit
cc98b7f7). Hashes computed via 'nix-prefetch-url --unpack' and
'prefetch-npm-deps package-lock.json' against the tagged tree.
cc
PR #406 was the macOS titlebar unification (mentioned separately in the
Title bar section); the sticky home header pop-in fix is PR #407. Both
from @afonsojramos in the same merge round.
cc
Discord RPC now reaches Discord-Flatpak's IPC socket without making the
integration opt-out at the sandbox layer. Bridge symlinks are created
lazily on first connect after the user enables the toggle; users who
never enable Discord RPC see no sandbox changes.
* prepare_flatpak_discord_socket_links in discord_rpc.rs runs from the
lazy connect path, gated on FLATPAK_ID env var
* Manifest gains xdg-run/app/com.discordapp.Discord:create filesystem
permission, kept alongside the existing static path
Discord-Flatpak writes its IPC socket to
\$XDG_RUNTIME_DIR/app/com.discordapp.Discord/discord-ipc-N rather than
the \$XDG_RUNTIME_DIR/discord-ipc-N path the discord-rich-presence crate
searches by default. The naive way to bridge that gap — a startup
wrapper that unconditionally symlinks every iteration before the binary
launches — works, but it pulls the integration off-script: every other
optional integration in QBZ (Last.fm, ListenBrainz, MusicBrainz, Plex)
is opt-in, with zero telemetry and zero filesystem reach until the user
flips the toggle. A startup wrapper would silently turn Discord RPC
into opt-out at the sandbox layer; the symlinks would exist for users
who never touched the integrations panel.
Move the bridge into the lazy connect path instead. The first time the
user actually connects — i.e. the first track plays after they enabled
the toggle — prepare_flatpak_discord_socket_links runs:
* No-op outside Flatpak (no FLATPAK_ID env var)
* No-op when \$XDG_RUNTIME_DIR/discord-ipc-N already exists (native
Discord, or a previous lazy run in this Flatpak instance)
* No-op when the Discord-Flatpak source path doesn't exist (Discord
not running)
* Otherwise, symlinks 0..9 from \$XDG_RUNTIME_DIR/discord-ipc-N to
\$XDG_RUNTIME_DIR/app/com.discordapp.Discord/discord-ipc-N
Symlinks die with the Flatpak instance — \$XDG_RUNTIME_DIR is per-
instance, so there's no cross-session pollution. Users who never enable
Discord RPC keep the same sandbox surface as before.
Manifest: keep the static xdg-run/discord-ipc-0 permission for native
Discord, add xdg-run/app/com.discordapp.Discord:create so the lazy
symlink path is allowed when the user opts in. The two coexist; the
crate's default search path covers Discord-native, the symlinks cover
Discord-Flatpak.
cc
Drains the wiki "23 warnings" baseline back to zero so future regressions
stand out:
* Drop 16 unprefixed \`app-region\` CSS lines across TitleBar, TitleBarNav,
ImmersiveHeader, ImmersivePlayer, MiniPlayerFooter and MiniPlayerWindowControls.
Tauri's webview is WebKit / Chromium on every platform we ship to, so
\`-webkit-app-region\` already covers the chrome-drag region; the
unprefixed property only existed to keep stylelint quiet but the CSS
spec doesn't define it and svelte-check warns on every site.
* Remove 2 empty rulesets in AlbumCreditsModal and NowPlayingBar (leftover
comment-only blocks from earlier refactors).
* Pair \`-moz-appearance: textfield\` with the standard \`appearance: textfield\`
in LocalLibraryTagEditorModal so the rule no longer trips the
property-companion warning.
* Delete the unused \`.quality-text\` selector in NowPlayingBar.backup.svelte
(file is a frozen snapshot of an older NPB iteration; the live NPB
doesn't use that class).
* Annotate Tooltip.svelte and immersive/panels/VinylPanel.svelte with
\`svelte-ignore a11y_no_static_element_interactions\` plus a comment
explaining the context — both are decorative wrappers (a tooltip hit
area; a 3D tilt effect on artwork) where adding an ARIA role would
misrepresent the semantics.
* Add \`@types/node\` as a devDependency (^22) so the tsconfig resolution
for the SvelteKit-extended config stops complaining about the missing
type definition.
cc