Commit Graph
4578 Commits
Author SHA1 Message Date
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
vicrodh 5ae5087faa feat(discovery-v2): drag-to-paginate, fade transition, bump to 20 items
Three small additions to make the section feel less brutalist without
adding paint cost:

  - Per-section item count: 8 → 20. Last page is no longer a single
    orphan card on wide screens. Fetch cost grows linearly (~5KB
    per album); render cost is unchanged because pagination caps
    mounted cards at `itemsPerPage`.

  - Drag-to-paginate. Pointerdown on the row tracks dragDistance;
    pointerup commits a page change if the gesture moved past
    PAGE_COMMIT_PX (60px). A capture-phase click swallow catches
    the bubbling click that would otherwise trigger a card's
    onClick. No transform during the drag — the swap happens on
    release. PAGE_COMMIT_PX = 60 picks the gesture earlier than a
    scroll feels; DRAG_THRESHOLD_PX = 5 lets click-to-play still
    fire on a near-stationary pointerup.

  - Page-change fade. `{#key page}` wraps the inner row so Svelte
    rebuilds it on page change, and `in:fade={{ duration: 120 }}`
    runs a 120ms opacity fade-in on the new content. The old block
    has no `out:` so it's removed instantly — single new layer at
    a time, no double-render window. Opacity-only animation is the
    cheapest visible transition under software compositing (no
    transform layer, no filter recompute, no backdrop).

`touch-action: pan-y` on the row-outer allows vertical page scroll to
work over the section while horizontal drags get paginated.

cc
2026-05-11 21:42:53 -06:00
vicrodh fa78f6af66 feat(discovery-v2): route artwork through cachedSrc
All four Lite cards (Album, Track, Playlist, Artist) now apply
`use:cachedSrc={url}` instead of `src={url}` on their <img> element.
This routes every artwork request through the existing image-cache
service:

  - In-memory `resolvedUrls` Map dedupes URLs across this session.
  - First fetch goes through the Rust backend (`v2_get_cached_image`)
    which downloads via rustls (own CA bundle), writes to the on-disk
    image_cache.db-backed store, and returns a `file://`-resolved URL.
  - Subsequent renders of the same album/track in any section hit the
    in-memory map synchronously and assign src without a Promise.

The `cachedSrc` action no longer applies the `will-change: transform`
+ `transform: translateZ(0)` workaround that was removed in efcdad55
because of its software-compositing cost — only the cache lookup runs,
so this is a pure win for Discovery V2.

cc
2026-05-11 21:39:36 -06:00
vicrodh 1ad08f2de8 feat(discovery-v2): pagination by DOM slice (no scroll containers)
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
2026-05-11 21:34:21 -06:00
vicrodh 368f0b4266 Revert "feat(discovery-v2): horizontal-scroll sections (Cider pattern)"
This reverts commit 612384ae4c.
2026-05-11 21:28:33 -06:00
vicrodh 612384ae4c feat(discovery-v2): horizontal-scroll sections (Cider pattern)
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
2026-05-11 21:25:38 -06:00
vicrodh f6ac95ec6b feat(discovery-v2): highlight currently-playing track in continue listening
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
2026-05-11 21:06:37 -06:00
vicrodh fcdd602149 feat(discovery-v2): wire genre filter button into toolbar
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
2026-05-11 21:05:16 -06:00
vicrodh 5c30ced81f feat(discovery-v2): gate scroll-area by active tab
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
2026-05-11 21:03:06 -06:00
vicrodh e40400449a feat(discovery-v2): wire personalized + playlist + artist sections
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
2026-05-11 21:01:33 -06:00
vicrodh aa526fb894 feat(discovery-v2): wire editorial sections from discover-index
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
2026-05-11 20:57:16 -06:00
vicrodh b6d2c12125 feat(discovery-v2): first section — new releases
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
2026-05-11 20:50:44 -06:00
vicrodh 373018a1ea feat(discovery-v2): scaffold clean-room rebuild of home view
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
2026-05-11 20:44:02 -06:00
vicrodh 03af983a1b chore(scripts): add measure-cpu-window for structured perf sampling
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
2026-05-11 20:43:49 -06:00
vicrodh 3c6f5f9023 perf(now-playing-bar): drop invisible backdrop-filter
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
2026-05-11 20:43:43 -06:00
vicrodh efcdad553c perf(cached-image): drop forced compositing layer on every artwork
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
2026-05-11 20:43:37 -06:00
vicrodh 82b3217c4f perf(playlist-card): drop blur(30px) on artwork backdrop
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
2026-05-11 20:43:23 -06:00
vicrodh 9aa10e9573 perf(album-card): drop hover marquee, decorative blurs, add content-visibility
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
2026-05-11 20:43:15 -06:00
vicrodh 6029e93f5f Revert "Merge pull request #407 from afonsojramos/fix-glued-top-animation"
This reverts commit 08acd304a1, reversing
changes made to f7623154f4.
2026-05-11 17:51:21 -06:00
vicrodh 43a35647fe Merge branch 'bugfix/unavailable-track-row-dim' into pre-release 2026-05-11 16:01:52 -06:00
vicrodh 03d1142fc1 fix(ui): dim unavailable tracks across all track list views
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
2026-05-11 16:01:47 -06:00
Afonso Jorge Ramos 4fd85b5b2f fix(ci): suffix macOS .app.tar.gz bundles with arch to fix auto-updater 2026-05-11 16:17:07 +02:00
Afonso Jorge Ramos 1edde8a28a fix(audio): rephrase CoreAudio rate-mismatch errors for end users
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.
2026-05-11 15:19:30 +02:00
Afonso Jorge Ramos 9e632386b9 fix(audio): retry CoreAudio shared-mode open on rate mismatch
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.
2026-05-11 15:16:46 +02:00
Afonso Jorge Ramos c408ea73cb test(player): cover compute_needs_new_stream decision rule
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.
2026-05-11 15:15:57 +02:00
Afonso Jorge Ramos ac60113cdd refactor(player): extract StreamRecreateDecision helper
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").
2026-05-11 15:14:54 +02:00
Afonso Jorge Ramos 3b34a734b4 refactor(player): rename current_sample_rate to current_track_sample_rate
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.
2026-05-11 15:13:29 +02:00
Vudgekek 7bad704e21 fix(audio): fix macOS Shared Mode playback from changing speed 2026-05-09 23:43:26 -05:00