Commit Graph
4605 Commits
Author SHA1 Message Date
vicrodh 8f2cd965b4 feat(local-library): server-paginated chunked store for Albums grid
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
2026-05-12 13:25:47 -06:00
vicrodh 810a41b4fc feat(local-library): recycling-pool virtualizer for Albums grid
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
2026-05-12 12:44:01 -06:00
vicrodh 3eb62a5076 feat(discovery-v2): AlbumCardLibraryLite + migrate LocalLibrary grid
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
2026-05-12 11:49:54 -06:00
vicrodh b2e88c47e9 refactor(settings): hard-disable chrome toggle without GPU, terser note
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
2026-05-12 11:34:50 -06:00
vicrodh d1ad0fc888 feat(window): disable transparent chrome when hardware acceleration is off
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
2026-05-12 11:32:55 -06:00
vicrodh 884da638e0 chore(scripts): add dev-cpu-mode.sh for forced software rendering
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
2026-05-12 11:32:43 -06:00
Afonso Jorge Ramos b5db0241a9 fix(LyricsLines): make line-text block to expand background-clip paint area 2026-05-12 18:24:37 +01:00
Afonso Jorge Ramos f9a9d103cb fix(LyricsLines): let EMA accept rAF-rate block measurements 2026-05-12 18:01:52 +01:00
vicrodh c68b1e286e refactor(modals): migrate all 22 shared-modal callers to ModalLite
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
2026-05-12 10:58:46 -06:00
Afonso Jorge Ramos f4f8fcf9bf fix(LyricsLines): use activeProgress as karaoke floor, lookahead as bonus 2026-05-12 17:58:16 +01:00
Afonso Jorge Ramos 4de657e2cd feat(LyricsLines): JS-driven karaoke with measured block-interval and lookahead 2026-05-12 17:57:35 +01:00
vicrodh dcb1356823 feat(discovery-v2): ModalLite — add withScrim + footerAlign + overlay backdrop
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
2026-05-12 10:56:19 -06:00
vicrodh d729c68dfd feat(discovery-v2): spotlight rewrite + artist follow + loading skeleton
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
2026-05-12 10:56:09 -06:00
Afonso Jorge Ramos 6d25100133 refactor(LyricsLines): single-div template with class-toggle transitions 2026-05-12 17:55:30 +01:00
Afonso Jorge Ramos 4d426a80cd fix(lyrics): snap line progress to 1 when at least 99% through 2026-05-12 17:54:01 +01:00
Afonso Jorge Ramos 959f656866 refactor(lyrics): notify listeners on every progress change 2026-05-12 17:53:49 +01:00
Afonso Jorge Ramos 13d1d6919a perf(lyrics): drive active-line updates via requestAnimationFrame 2026-05-12 17:53:35 +01:00
Afonso Jorge Ramos a5100a329a perf(page): memoize lyrics sidebar lines projection 2026-05-12 17:53:06 +01:00
Afonso Jorge Ramos 9aacf1d7eb feat(lyrics): parse LRC gap markers and use endMs for line bounds 2026-05-12 17:52:48 +01:00
vicrodh 556a345cbd feat(discovery-v2): RadioCardLite + Spotlight radio + Mixes resize + i18n fixes
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
2026-05-12 10:21:17 -06:00
vicrodh 5a8d3a2023 feat(discovery-v2): For-You-exclusive sections (Spotlight, Rediscover, etc.)
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
2026-05-12 10:09:06 -06:00
vicrodh fdd8de48b9 feat(discovery-v2): per-tab section preferences (Home / Editor's / For You)
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
2026-05-12 09:50:26 -06:00
vicrodh 704392d30b feat(runtime): conditional flicker fix gated by HW-accel state
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
2026-05-12 09:45:24 -06:00
vicrodh da1b29b212 feat(graphics): default hardware_acceleration to 1 (was 0)
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
2026-05-12 09:37:10 -06:00
vicrodh e9504efa24 perf(modals): gate 8 always-mounted modals with {#if}, drop blur shadow
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
2026-05-12 09:16:34 -06:00
vicrodh e3cdc97a1d feat(discovery-v2): ModalLite — opacity-only animation, no scale transform
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
2026-05-12 08:26:43 -06:00
vicrodh 7696b1ee98 fix(discovery-v2): prioritize Album of the Week over other ribbons
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
2026-05-12 08:21:38 -06:00
vicrodh 86195fcb02 feat(discovery-v2): rephrase queue + offline actions, reorder
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
2026-05-12 08:19:52 -06:00
vicrodh 35520e9335 feat(discovery-v2): playlist hover overlay + quick menu (refactored, not ported)
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
2026-05-12 08:17:08 -06:00
vicrodh 87423e92f5 feat(discovery-v2): ribbon to bottom, meta to top-left, actions raised
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
2026-05-12 08:11:52 -06:00
Afonso Jorge Ramos fdeb544c63 fix(lyrics): correct active-line color on macOS WebKit 2026-05-12 15:10:09 +01:00
vicrodh 2baa2e284b feat(discovery-v2): responsive Top Albums grid + Hi-Res only badge + meta tweak
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
2026-05-12 08:06:46 -06:00
vicrodh 9285a3808a feat(discovery-v2): Top Albums rank counter + hover-play + kebab menu
Matches the Qobuz web player's "Popular albums" row layout:

  - Rank number on the left (1-based, derived from the visible-grid
    index). Renders by default as a muted-color numeral with tabular
    figures so columns align cleanly.
  - On row hover the rank fades out and a circular play button takes
    its place in the same 32px cell. Opacity-only swap (120ms), no
    transform on the parent row.
  - Quality badge ("Hi-Res 24bit/96kHz", "CD Quality") in a compact
    pill on the right, before the kebab.
  - Kebab on the far right; hidden at rest, fades in on row hover.
    Opens the existing AlbumQuickMenu (same surface as AlbumCardLite,
    so the same six actions: Play Next, Play Later, Add to Playlist,
    Go to Album, Go to Artist, Share Qobuz, Share Songlink, Download)
    via shared `floatingMenuStore`.

DiscoveryGridSection's `renderItem` snippet signature gains a second
parameter for the global index (`page * itemsPerPage + idx`). Snippets
that ignore the arg (the existing track-row consumer) work unchanged.

cc
2026-05-12 07:51:45 -06:00
vicrodh b4e08ac5e3 feat(discovery-v2): wire add-to-playlist + songlink share to album quick menu
Completes the AlbumQuickMenu surface with the two remaining actions the
parent already passes through Props but Discovery hadn't been forwarding:

  - Add to Playlist (onAddAlbumToPlaylist → onAddToPlaylist on the
    menu): opens the parent's add-to-playlist modal.
  - Share Songlink (onAlbumShareSonglink → onShareSonglink): copies the
    Songlink URL to the clipboard via the parent's existing flow.

i18n: adds `actions.shareSonglink` ("Share (Songlink)") across the five
locales. `actions.addToPlaylist` already existed and is reused. The two
actions render only when the corresponding callback is passed, so cards
on sections that don't wire them (no current case) silently omit the
items.

Reuses the same AlbumQuickMenu instance — no new component, just two
extra optional props forwarded through AlbumCardLite. This sets up the
shared menu surface that the upcoming AlbumRowLite kebab will reuse for
the Top Albums section, avoiding duplicate menu logic across cards.

cc
2026-05-12 07:49:14 -06:00
vicrodh 5d33db2d14 feat(discovery-v2): wire kebab to minimal action quick-menu
Replaces the stub `onMenu` callback with a real inline popover.

`AlbumQuickMenu.svelte`: portaled action list, six entries:
  - Play Next
  - Play Later
  - Go to Album (uses the card's own onClick)
  - Go to Artist (uses onArtistClick)
  - Share on Qobuz
  - Download

Each entry only renders if its callback is provided, so cards without a
particular handler (e.g. tracks without an artist link) get a shorter
menu. Positions itself near the kebab via `getBoundingClientRect` and
clamps to the viewport. Integrates with the shared `floatingMenuStore`:
opening this menu auto-closes any other floating menu in the app, and
vice versa. Outside-click closes. No backdrop-filter, no animation
beyond the implicit mount — single popover at a time, minimal cost.

AlbumCardLite now hosts the menu inline. DiscoveryView forwards the
remaining four parent callbacks (onAlbumPlayNext, onAlbumPlayLater,
onAlbumShareQobuz, onAlbumDownload) to the card. Songlink share,
add-to-playlist, and per-album re-download are intentionally omitted
from V1 — they require more parent state plumbing and can land in a
follow-up.

i18n: adds `actions.playLater` and `actions.download` across the five
locales. `actions.playNext`, `goToAlbum`, `goToArtist`, and `share`
already existed and are reused.

cc
2026-05-11 23:14:16 -06:00
vicrodh d9c9ec1141 feat(discovery-v2): genre/year meta + slide-up actions + bottom positioning
Polish pass on the hover overlay matching the Qobuz web pattern more
closely:

  - Genre + year metadata renders top-right on hover (opposite the
    award ribbon at top-left, when present). White text with a 1px
    text-shadow for legibility on lighter artwork. Stacks vertically;
    genre line on top, year smaller below.
  - DiscoveryAlbumCard gains `genre?: string` and `releaseYear?: number`
    fields. The data mappers extract:
      * QobuzAlbum (release-watch): `album.genre.name`,
        `release_date_original` parsed by regex to YYYY.
      * DiscoverAlbum (discover-index): `album.genre.name`,
        `dates.original` parsed the same way.
    The home-resolved Rust shape doesn't carry these yet, so recently
    played / favorite albums simply omit the meta in the hover overlay
    (no broken render).
  - Action buttons move from dead-center to `bottom: 20px; left: 50%`,
    matching the Qobuz layout. The center area was visually competing
    with the cover artwork; bottom-center keeps the artwork dominant.
  - Slide-up on entrance: rest state has `translateY(10px)` on the
    `.actions` container, hover state has `translateY(0)`. Transform-
    only animation on a single element with the existing opacity
    transition; no extra layer churn.

cc
2026-05-11 23:09:13 -06:00
vicrodh 871e550da3 feat(discovery-v2): Qobuz-style hover overlay with 3 action buttons
Replaces the bottom-gradient + corner play button with the full-cover
hover overlay pattern from the Qobuz web player:

  - Dark scrim covers the whole artwork on hover (rgba(0,0,0,0.6)
    instead of bottom-fade gradient). Single `::after` element, pure
    opacity transition.
  - Three outline-style action buttons centered on the cover:
       * Primary play (filled white circle, 44px)
       * Favorite/heart (outlined circle, 36px; fills + accent color
         when isFavorite=true)
       * Menu/kebab (outlined circle, 36px; emits onMenu — parent will
         wire to context menu in a follow-up)
  - All three only visible on hover via opacity + pointer-events. No
    slide-in keyframes, no scale-up entrance, no blur. The buttons sit
    on a separate `.actions` container above the scrim — both fade in
    together via the same 150ms transition.

DiscoveryView subscribes to `albumFavoritesStore` and bumps a `$state`
version counter on each notification so per-card `isFavorite` reads in
the snippet re-evaluate when the favorites set changes. `onFavorite`
toggles via `toggleAlbumFavorite(albumId)` which already handles the
optimistic V2 round-trip + cache sync.

i18n: adds `actions.toggleFavorite` and `actions.moreActions` across
the five locales (en/es/de/fr/pt) for aria-label.

The `onMenu` callback is stubbed at the DiscoveryView level — clicking
the kebab does nothing yet. Wiring it to a context menu with the queue
/ share / download actions the parent already exposes via Props is the
next iteration.

cc
2026-05-11 23:03:13 -06:00
vicrodh c15102016f feat(discovery-v2): cheap hover overlay on album cards
Two CSS-only hover effects, both compositor-friendly under software
rendering (no blur, no filter, no backdrop-filter, no transform on
parent):

  1. Cover gradient overlay. A `::after` pseudo-element on `.cover-wrap`
     with a transparent → dark gradient covering the lower half of the
     artwork. Default opacity 0; opacity 1 on hover. Pure opacity
     transition (150ms), single gradient, fixed at the same dimensions
     as the cover — WebKit can re-use the cover layer and just composite
     the overlay on top.

  2. Play button scales 1.12 on card hover, 1.18 on direct hover.
     Transform-only animation; no layout reflow. Bumped base size from
     32px to 36px so the resting state reads more clearly. z-index: 1
     so it sits above the gradient.

Net visual: card looks alive on hover (subtle vignette + bigger play
affordance) without the prior overlay's `backdrop-filter: blur(12px)`
cost that made hover-during-scroll catastrophic at 4K.

cc
2026-05-11 22:57:56 -06:00
vicrodh f9479ac8e5 feat(discovery-v2): Popular albums as compact 4×3 grid
Mirrors the dense layout of "Recently Played Tracks" — same 44px thumb
+ title/artist text — but for albums. The mostStreamed (Popular Albums)
section now renders via DiscoveryGridSection with the new
`AlbumRowLite.svelte` (click navigates to album detail, click on
artist navigates to artist page; no inline play button — distinct from
the album-card's permanent play affordance).

Items are sliced to 12 in DiscoveryView before passing in so the grid
fills exactly one 4×3 page. The grid section now hides the prev/next
chevrons when `totalPages === 1`, keeping the "See all" link as the
only action when paging isn't applicable.

cc
2026-05-11 22:56:24 -06:00
vicrodh 3e4e04a489 feat(discovery-v2): playlist cover with object-fit:contain + dominant color bg
Playlist covers from Qobuz aren't always square — Apple-Music-style
rectangle banners and tall portrait variants get distorted under
`object-fit: cover`. Switches to `contain` and adds a same-card
background pulled from the cover's dominant color via the existing
artworkPalette utility (Vibrant-backed, internally cached so same URL
across sections doesn't re-extract).

  - cover-wrap now flexes its child centered both axes; the <img>
    constrains via max-width/max-height so it letterboxes when the
    aspect doesn't match the 220x220 cell.
  - dominantBg state is populated by `extractPalette(image)` in an
    $effect; falls back to `--bg-tertiary` until the palette resolves
    (or stays there permanently if the extract fails — Qobuz CDN's
    inconsistent CORS on artist images means we route through the
    local image cache first, which artworkPalette already does).

cc
2026-05-11 22:45:23 -06:00
vicrodh 0c8cde7598 feat(discovery-v2): compact track-row grid for "Continue Listening"
Replaces the album-card-styled track entries with a denser horizontal-row
layout that matches the visual the user wants for "Recently Played
Tracks" (44px thumb on left, title + artist text right). Same affordance
as the Apple Music shelf in the reference screenshot.

  - `TrackRowLite.svelte`: 44px square thumbnail + two-line text. No
    play button — clicking the row plays the track. Inline `cachedSrc`
    for cover art. Cero efectos (no hover blur, no transition).

  - `DiscoveryGridSection.svelte`: fixed-cell grid pagination variant
    of DiscoverySection. `itemsPerPage = columns × rows` (default 4×3
    = 12). CSS grid with `repeat(N, 1fr)` columns instead of the flex
    row used by album cards. Same chevron / drag / fade transitions.

  - DiscoveryView wires "Continue Listening" through the new section
    + the new snippet (`trackRow`). The previous `TrackCardLite`
    (180px square card) is removed — it was only ever consumed by this
    section.

cc
2026-05-11 22:43:35 -06:00
vicrodh 42f5903306 feat(discovery-v2): quality badges + press / award ribbons on album cards
Two card metadata bits restored from the original AlbumCard, freshly
implemented for the Lite card:

  - Quality badge: "24bit/96kHz", "CD Quality", etc. Static text in a
    small bordered pill at the bottom of the card. Pulled from
    `audio_info.{maximum_bit_depth,maximum_sampling_rate}` on
    DiscoverAlbum, `{maximum_bit_depth,maximum_sampling_rate}` on
    QobuzAlbum (release-watch), and the already-formatted
    `quality` string the V2 backend returns from
    `v2_reco_get_home_resolved`.

  - Award ribbon: small label on the top-left of the artwork. Picks
    the LAST award in `album.awards` and maps by id:
       - '88'  → qobuzissime  (purple left-border accent)
       - '151' → albumOfTheWeek (yellow left-border accent)
       - else  → press (gold gradient background)
    Same id mapping the legacy HomeView used. `pointer-events: none`
    so it doesn't intercept clicks meant for the card.

DiscoveryAlbumCard gains `quality?: string` and `ribbon?: AlbumRibbon`
fields populated at the data-mapper layer. The `albumCard` snippet in
DiscoveryView forwards both to AlbumCardLite.

cc
2026-05-11 22:41:29 -06:00
vicrodh b9a902a882 chore(discovery-v2): per-section item count 6 → 18
Reverts the diagnostic-only drop to 6 (used to verify whether item
density was the vertical-scroll bottleneck — it wasn't; the wheel
event handling was the root cause). Restores the user-facing default
of 18 items per section, which gives ~3 pages at FullHD and ~2 at 4K
with the current 220px card size.

cc
2026-05-11 22:38:29 -06:00
vicrodh 29a6f386ce perf(zoom): only register wheel listener while Ctrl/Meta is held
The zoom-on-Ctrl-wheel handler was registered on `window` for the
lifetime of the app with `{ passive: false, capture: true }`. That
flag combination forces the browser to wait for the handler to return
on EVERY wheel event before applying scroll — even though the handler
exits at line 1 when no modifier is held. The user reported a
characteristic input lag: scrollbar-drag was perfectly smooth, mouse-wheel
was choppy. His description ("como si la ventana principal lo cachara y
lo mandara al div interno y eso causara ese input lag") was literal —
the capture-phase non-passive listener intercepts the event at window
before routing to the scroll container.

Refactor: track Ctrl/Meta state via keydown/keyup and register the
non-passive wheel listener only while a modifier is actually held.
Default state = no wheel listener at all → browser native fast-path.
`window blur` releases the listener if the user alt-tabs with Ctrl held
(otherwise keyup never fires).

cc
2026-05-11 22:38:23 -06:00
vicrodh 67118b21e4 perf(back-to-top): keep button mounted, swap mount/unmount for opacity
The previous `{#if showGlobalBackToTop}` block mounted/unmounted a
`position: fixed` button (with box-shadow) every time the user scrolled
past the 800px threshold. On 4K under software compositing the
unmount path (going up across the threshold) registered as a visible
hiccup — the user perceived it as a stutter halfway through scrolling
back to the top.

The button is now always mounted. Visibility is driven by a `.hidden`
class with `opacity: 0; pointer-events: none` and a 150ms opacity
transition. `tabindex` and `aria-hidden` reflect the boolean for a11y.

Also rewrites the scroll listener: previously each scroll event wrote
to a `$state<number>` and triggered a `$derived` recompute (60-120
times/second during active scrolling). Now the listener
rAF-coalesces and only assigns when the threshold actually flips.
Default scroll cost drops from "per-event reactive churn" to
"one boolean flip per threshold cross".

cc
2026-05-11 22:38:12 -06:00
vicrodh e477d0ce8e perf(modal): drop backdrop-filter blur on overlay
`Modal.svelte:87` had `backdrop-filter: blur(4px)` on the full-viewport
overlay. At 4K with software compositing this means the first-paint of
every modal applies a blur computation across the entire window — the
visible cost the user reported when opening the new Discovery customize
modal ("tarda un poquito en cargar").

The 70% opaque dark scrim (rgba(0,0,0,0.7)) already carries the
dimming effect; the blur was decorative noise on top of an opaque-enough
layer. Aligns with project policy "no backdrop-filter / blur in modals"
and clears one of the P1 items from the 2026-05-11 architecture review.

Affects every modal that uses the shared component, not just Discovery
V2's settings modal. All consumers benefit.

cc
2026-05-11 22:06:06 -06:00
vicrodh 3fb9e5540e feat(discovery-v2): customize modal + gear icon + bigger gaps
Three small UX additions:

  1. Discovery settings modal (`DiscoverySettingsModal.svelte`)
     - Wraps shared `Modal.svelte` (z-index, ESC, portal, backdrop).
     - Lists all 12 sections with checkbox toggle + up/down reorder
       arrows. No drag-to-reorder for V1 — chevrons are simpler and
       work with keyboard.
     - Performance warning at the top: "Each enabled section adds to
       the work done on every scroll" with TriangleAlert icon.
     - Live "N of M sections enabled" counter.
     - Reset-to-defaults button in footer.

  2. Gear icon in the toolbar to the right of the genre filter. Opens
     the settings modal. Uses lucide Settings glyph.

  3. Spacing tuning:
     - Inter-card gap: 16px → 32px (doubled). Matches the `gap` JS
       constant used by itemsPerPage math.
     - Inter-section bottom margin: 32px → 48px (~50% more breathing
       room between rows).

i18n: adds `discovery.{customize,perfWarning,enabledCount,moveUp,
moveDown,resetDefaults}` across all five locales (en/es/de/fr/pt).

cc
2026-05-11 21:56:33 -06:00
vicrodh efa7b2b15c feat(discovery-v2): section preferences (default 7 enabled)
Adds `src/lib/discovery-v2/sectionPrefs.ts`: a writable store of
ordered section preferences persisted to localStorage. Each entry is
`{ id, enabled }`. The array order is the render order.

Defaults — seven sections enabled to keep initial DOM cost bounded
under software compositing on wide displays:

  - newReleases, pressAwards, qobuzPlaylists, recentlyPlayedAlbums,
    continueListening, idealDiscography, mostStreamed

Available but off by default (long-tail content for users with rich
play history or specific curation interest):

  - releaseWatch, editorPicks, qobuzissimes, topArtists, favoriteAlbums

DiscoveryView's section block becomes data-driven: `{#each $sectionPrefs}`
walks the persisted order, conditionally rendering each section's
DiscoverySection (only if enabled AND the items array is non-empty).
The empty-state placeholder is unchanged.

`migrate()` in the store reconciles persisted prefs against the
canonical section ID set so adding new sections in the future appends
to the tail of an existing user's list rather than dropping their saved
order. Toggle/move/reset helpers are exported for the upcoming
settings modal.

cc
2026-05-11 21:53:18 -06:00
vicrodh 5b608e1daf feat(discovery-v2): bump card size 180px → 220px (matches image.small)
`getQobuzImageForSize(_, 'small')` returns the 230px URL variant. The
existing 180px render scaled DOWN — wasted pixels, no quality benefit.
Bumping to 220px uses the artwork at its native size minus a hairline,
and importantly drops the number of cards visible per page on wide
displays:

  - 4K maximize: ~12 cards/page → ~9 cards/page
  - 1080p:       ~5 cards/page  → ~4 cards/page

Fewer cards = less DOM = less paint surface to walk during vertical
scroll. Combined with future section-prefs work, the goal is to keep
the visible card count bounded under software compositing.

Artist tiles (circular) scale proportionally: 140px → 170px.
`cardWidth` default in DiscoverySection updated to 220 (was 180); the
explicit 170 override for the topArtists section follows.

cc
2026-05-11 21:51:15 -06:00
vicrodh fb987366e5 feat(discovery-v2): wire ideal-discography section
The DiscoverContainers response carries an `ideal_discography` container
alongside `new_releases`, `press_awards`, etc. — same DiscoverAlbum shape,
no extra round-trip. The original HomeView consumed it via a separate path
(its own `$state<DiscoverAlbum[]>($state)`); Discovery V2 pulls it out of
the existing `v2_get_discover_index` response and renders it through the
same `albumCard` snippet.

Uses the existing i18n key `discover.idealDiscography` ("Ideal Discography"
/ "Discografía Ideal") which already lives in all five locales.

cc
2026-05-11 21:48:54 -06:00