Commit Graph
4605 Commits
Author SHA1 Message Date
vicrodh 427e1b5bec Merge branch 'feature/multiselect-completion-381' into pre-release 2026-05-13 10:48:52 -06:00
vicrodh 58a80b57d0 feat(favorites): select-all checkbox for track multiselect
Adds a tri-state Select-All checkbox in the Tracks tab toolbar of
FavoritesView, mirroring the local-library tracks tab. State is computed
over the currently filtered tracks so genre/search filters narrow the
selectable set, matching user expectation for the visible-set semantics
established in #381.

cc
2026-05-13 10:48:48 -06:00
vicrodh 2e2c7549fc feat(local-library): finalize #381 multiselect with cross-context apagador
Completes the multiselect rollout in LocalLibraryView and the folder/album
view:

- Select-All checkbox in the folder-album track listing, mirroring the
  tracks-tab toolbar pattern.
- Shift-click anchor and ctrl-a effect for track rows in the folder-album
  view.
- Cross-context mutual exclusion ("apagador") between tree-select,
  album-select, track-select and the folder-album view's selection mode:
  entering any context clears the others' selections and turns off their
  selection mode, so two contexts cannot be active simultaneously.

The mutex is plumbed via parentSelectionActive prop + onSelectionEntered
callback between LocalLibraryView (orchestrator) and the
LocalLibraryFolderAlbumView child, matching the simpler single-switch
mental model the user requested.

cc
2026-05-13 10:48:42 -06:00
Victor RH 000f7c10d2 Merge pull request #418 from afonsojramos/fix-macos-lyrics-text-color
fix(lyrics): active-line color and karaoke playback overhaul
2026-05-13 10:44:26 -06:00
Afonso Jorge Ramos 84dcd7ebbe perf(+page): guard lyrics state writes against subscriber churn
subscribeLyrics fires per rAF tick (~60Hz with extrapolation). The
previous version wrote all six $state slots unconditionally, which
re-triggered the lyricsSidebarLines $derived (a .map allocating N
objects per call) every tick even though lyricsLines itself only
changes per-track. Adding reference/value guards short-circuits the
fan-out for unchanged fields — the per-tick path now only writes
lyricsActiveProgress (and sometimes lyricsActiveIndex).
2026-05-13 14:31:19 +01:00
Afonso Jorge Ramos 640dead393 fix(lyricsStore): claim rAF handle pre-tick and notify on every change
Two related fixes to the active-line rAF loop:

- The previous guard reassigned updateRafHandle *after* updateActiveLine
  returned. Any listener that synchronously re-entered
  startActiveLineUpdates during notifyListeners() saw a null handle and
  scheduled a second parallel rAF chain. Now the handle is claimed
  before the work runs, so re-entry is a no-op.

- Drops the inter-tick progress threshold. With playerStore extrapolating
  audio time between backend events, every rAF tick has a freshly-changed
  progress value. Threshold-throttling would skip the edge samples where
  the snap-to-1 and indexChanged transitions need to land — re-introducing
  the "starts midway"/"ends before 100%" bugs. Downstream consumers
  equality-guard their $state writes, so fan-out only fires on real
  changes.
2026-05-13 14:31:11 +01:00
Afonso Jorge Ramos 6166584ee8 perf(playerStore): extrapolate audio time between backend position events
The Rust backend emits playback:state at 250ms cadence. Frontend
getCurrentTime() was returning that staircase value — for the karaoke
rAF loop running at 60Hz, every 15 frames got the same answer, then a
250ms jump. This caused two visible artifacts:

- Active line appears to "start midway" when detection of the new index
  lags by up to 250ms past the line's timeMs (audio is already that far
  into the line by the time we see the boundary).
- Active line gets demoted before its highlight reaches the end, because
  the last in-line notification reports progress ~0.92 (one tick before
  bound) and the next backend tick has already crossed into the next
  line — the snap-to-1 never gets a chance to fire.

Audio position is wall-clock-based on the backend (see the 250ms emit
loop comment in lib.rs); we now do the same math in JS: anchor
(positionSecs, performance.now()) on every event-driven update, then
return anchor + elapsed while playing. Cap extrapolation at +1s so a
stalled backend doesn't run away. Existing 33 playerStore tests pass
unchanged — they read currentTime directly, not getCurrentTime().
2026-05-13 14:31:03 +01:00
Afonso Jorge Ramos 64511c4fa2 feat(lyrics): segment active-line gradient by visual line via pretext
Pre-pretext, the karaoke gradient applied to a single span shared the same
horizontal cut across every wrapped visual line — line 2 appeared partially
sung when only line 1's worth of vocals had played. Pretext lays out the
active lyric into visual lines; each renders as its own block-level span
with its own 0->100% gradient, so progress fills sequentially.

Also adds race/perf safeguards layered on top:
- Cancellation token on tick().then(layoutActiveLine) so rapid activeIndex
  changes (seek-scrub, fast songs) don't queue stale microtasks against
  the current DOM.
- ResizeObserver coalesces via rAF and ignores sub-pixel width deltas, so
  window-drag and the active-line scale animation don't trigger Pretext
  re-measurement every callback.
- segmentBounds prefix array memoizes cumulative widths, dropping per-
  segment progress lookup from O(i) to O(1) inside the each-block.
- EMA writes equality-guard measuredBlockMs/Delta and lookaheadEnabled so
  Svelte signals don't fan out no-op updates 60 times per second.
- Lookahead clamped at 1/30 and disabled on backward dp so a coarse audio
  jump or in-line seek can't poison the visual lead.
- measuredBlockMs seed lowered (>=20ms, dur * 0.01) since rAF-rate data
  converges the EMA within 2-3 ticks; the prior 80ms floor visibly lagged
  the gradient on the first frame of every new line.
- LyricsLine type unified from store (single source of truth).
2026-05-13 14:30:54 +01:00
Afonso Jorge Ramos 57f8307934 feat(ui): toast on audio:init-failed event
Listen for the backend audio:init-failed event and show a user-facing
error toast (8s) with the backend-provided cause. Mirrors the
audio:device-missing listener pattern alongside it. Surfaces the
silence when macOS CoreAudio init fails, instead of leaving the user
guessing why playback didn't start.
2026-05-13 13:09:35 +01:00
Afonso Jorge Ramos a251849094 i18n(toast): add audioInitFailed translation key
Add localized strings for the new backend audio init failure toast
(en, de, es, fr, pt). Reason placeholder receives the backend's
already user-friendly message (e.g. "macOS audio device changed its
sample rate during stream open...").
2026-05-13 13:09:31 +01:00
Afonso Jorge Ramos 6d91ec0e02 feat(audio): emit audio:init-failed Tauri event on backend init failure
The playback-event polling loop now drains
SharedState::take_stream_error_message and emits a Tauri
audio:init-failed event with the user-readable cause. Drain semantics
guarantee the toast fires exactly once per error instead of every
tick of the polling loop.
2026-05-13 13:09:26 +01:00
Afonso Jorge Ramos 04e17cc449 feat(player): expose user-readable stream_error_message channel
Pairs the existing stream_error AtomicBool with an Option<String> that
the audio thread can populate when a stream init failure has a
meaningful cause to surface to the user (currently: the macOS
CoreAudio rate-mismatch path).

set_stream_error(false) also clears the message; record_stream_error
sets both atomically; take_stream_error_message drains so the UI
fires its notification exactly once per error.
2026-05-13 13:09:18 +01:00
Afonso Jorge Ramos a7b40bf6bb perf(player): cache CoreAudio nominal rate query for 750ms
evaluate_stream_recreate runs on every track change on the audio
thread, and each call issued two HAL syscalls
(resolve_output_device_id + get_nominal_sample_rate). Cache the result
per-thread for 750ms — long enough to absorb the bulk of repeats
within an album, short enough to notice a system rate change.

Cache invalidates on device-name change, so switching the output
device still triggers a fresh query.
2026-05-13 09:23:39 +01:00
Victor RH 83fb109b26 Merge pull request #416 from afonsojramos/debug-local-update-issue
fix(ci): suffix macOS .app.tar.gz bundles with arch to fix auto-updater
2026-05-12 23:55:57 -06:00
Victor RH da3c1e86ee Merge pull request #410 from Vudgekek/fix/internal/macos-shared-mode
fix(audio): fix macOS Shared Mode playback from changing speed
2026-05-12 23:51:44 -06:00
vicrodh f5e00e0a24 Merge branch 'feature/cpu-mode-lite-panels' into pre-release
cc
2026-05-12 21:22:06 -06:00
vicrodh 4b29030820 feat(immersive): CPU-mode lite path + per-panel toggles + warning modal + badge unification
Comprehensive set of changes that make immersive mode usable on
machines without hardware-accelerated GPU compositing (Wayland NVIDIA
with the broken driver path, software-rendered llvmpipe, VMs, etc.).

Detection lives in `graphicsState.ts` — when `bootstrapGraphicsState()`
resolves and `hardware_accel_enabled` is false, we add an
`html.no-hwaccel` root class. Every CPU-mode rule below gates itself
on that class so GPU users see zero change.

Chrome-level work (ImmersivePlayer wraps every panel):

  - Blanket CSS rule on `.immersive-player` and descendants kills
    every `transition`, `animation`, `box-shadow`, and `text-shadow`.
    Box-shadows with 32-60px blur radii were the dominant per-frame
    cost — confirmed game-changer when stripped (user-tested on
    Visualizer with `0 8px 32px + 0 20px 60px` halo around the
    artwork).
  - `.player-bar`, `.window-menu`, `.tabs`, `.viz-dropdown`,
    `.viz-submenu`, and the quality-badge backdrop become opaque
    `#000` and drop `backdrop-filter` (40px / 16px / 12px blurs).
  - Whitelisted animation exceptions: the 4-bar equalizer in
    Coverflow's now-playing indicator (4 x 3px scaleY, basically
    free) and `fadeInUpLite` for LyricsFocus line changes
    (opacity + transform only, no `filter: blur`).

Per-panel LOW_PROFILE flag (captured from `isHardwareAccelEnabled()`):

  - Oscilloscope: skip `shadowBlur: 8` glow, fake with fat-underlay
    + thin-opaque stroke pair; force dpr=1.
  - Lissajous: skip shadowBlur: 6 with same trick, trail 8 -> 3
    frames, drop the 85-fillRect-per-frame dot cloud, hide the
    blurred watermark, dpr=1.
  - EnergyBands: drop per-band energy-driven shadowBlur (up to
    ~20px x 5 rings); halo-arc + ring trick; dpr=1.
  - Visualizer: taller cubes (14px vs 6px, half the count per bar);
    drop the per-cubelet gradient entirely so the whole frame uses
    a single `fillStyle`; dpr=1.
  - Coverflow: snap side-cover transitions (only `.center` keeps
    its 500ms transform); neutralize hover transforms on side
    covers (rotateY + scale 1.05 forces re-rasterization under
    software perspective); side covers go to opacity:1 so they
    don't pay alpha compositing.

LyricsLines split mode in immersive: restore line transitions for
opacity, color, and transform (all compositor-only). Skip the
font-size transition because that one forces re-layout per frame.

New per-panel enable/disable store (`immersivePanelsStore.ts`):

  - localStorage-backed `Record<ImmersivePanelId, boolean>`.
  - In CPU mode the five inherently-heavy panels default to
    disabled: AlbumReactive, NeonFlow (Laser), TunnelFlow,
    CometFlow, TransientPulse. These rely on shaders, particle
    systems, or dense per-pixel filter passes that simply cannot
    work in software compositing.
  - `applyCpuModeDefaultsIfNeeded()` re-applies defaults after
    `bootstrapGraphicsState()` resolves, so first-launch CPU users
    don't have to relaunch to see the heavy panels hidden. Wiring
    lives in `bootstrap.ts` to avoid a circular import between
    the store and graphicsState.
  - ImmersiveHeader filters the visualizer dropdown and Neon
    submenu by enabled state; if all three Neon variants are off,
    the submenu item disappears entirely. The `Visualizer` main
    tab falls back to the first enabled viz option instead of
    hardcoded spectral-ribbon.

First-time CPU-mode warning modal (`CpuModeWarningModal.svelte`):

  - Built on ModalLite. Triggered from `+page.svelte` via $effect
    when immersive opens, HW accel is off, and the user has not
    dismissed the warning before. Disarmed for the rest of the
    session after fire.
  - "Don't show again" checkbox writes
    `qbz-cpu-mode-warning-shown-v1` to localStorage when checked.
  - Explains which panels are hidden and points to Settings ->
    Appearance -> Composition and -> Immersive Panels.

Settings UI: new "Immersive Panels" collapsible section above the
existing "Immersive FPS" section in Settings -> Appearance. Per-panel
toggle, with a GPU badge + warning paragraph on the five heavy panels
when running in CPU mode.

QualityBadge unification (out of CPU-saver scope, addressed in same
branch by user request): every immersive callsite that previously
used the reactive `QualityBadge` (compact or full) now renders
`QualityBadgeStatic` — the same component used in Collection /
Mixtape detail lists. Sixteen callsites total: nine canvas panels
(compact mode), five center-content panels (full mode), plus
ImmersivePlayer split mode and ImmersiveControls. `QualityBadgeStatic`
gains `text-align: start` on its `.badge-text` block so it renders
correctly inside parents that center their content (VisualizerPanel,
StaticPanel, etc.).

i18n: new keys for `settings.appearance.immersivePanels.*` (title,
summary, desc, cpuWarning, heavyTooltip, warningModal.{title,body,
hint,dontShowAgain,acknowledge}) across en/es/de/fr/pt.

cc
2026-05-12 21:21:59 -06:00
vicrodh f528129fab Merge branch 'feature/412-track-list-album-covers' into pre-release
cc
2026-05-12 18:22:12 -06:00
vicrodh 145831fec6 feat(local-library): opt-in album artwork in tracks/folders list (#412)
User request: show the album cover thumbnail next to each track in
the Local Library Tracks and Folders list views (same UX the Qobuz
tracks and playlist views already offer).

Default OFF per the 2026-05-12 user call: a library with 16k+ local
tracks would render that many decoded images per scroll session,
and the user shouldn't opt into that without knowing. The setting
lives in `libraryPerformanceStore` (`showTrackArtwork: false` by
default) alongside the other library-perf knobs.

Frontend wiring:

  - `libraryPerformanceStore.ts` — new `showTrackArtwork` field,
    `isTrackArtworkEnabled()` getter, plumbed through `updateSetting`.
  - `LocalLibraryView.svelte` — subscribes to the store, passes
    `showArtwork={showTrackArtwork}` and a
    `getArtworkUrl={(track) => getArtworkUrl(track.artwork_path)}`
    bridge to `VirtualizedTrackList`. `VirtualizedTrackList` and
    `TrackRow` already supported these props (used by Playlist
    detail view).
  - `SettingsView.svelte` — new toggle in Appearance, next to the
    "Show playlist cover collages in sidebar" toggle. Uses the same
    Toggle component and same persistence pattern.

i18n: new `settings.appearance.localLibraryTrackArtwork` +
`settings.appearance.localLibraryTrackArtworkDesc` keys across all
five locales.

cc
2026-05-12 18:20:56 -06:00
vicrodh 9abc82b872 Merge bugfix/411-album-title-prefer-metadata into pre-release 2026-05-12 18:11:08 -06:00
vicrodh bdf6ca30e5 fix(local-library): prefer album metadata over folder-derived title (#411)
User-reported: Local Library Albums view showed the folder name as
the album title even when the album tag in the track metadata had
a different value — e.g. folder `Alle Songs` shadowed metadata
`ALBUM.`. Cause: the three metadata-grouped album queries (full-load
`get_albums_metadata_grouped`, chunked `get_albums_metadata_page`,
and `count_albums_metadata_for_page`) projected the title via
`COALESCE(album_group_title, album, 'Unknown Album')`.

`album_group_title` is the snapshot the scanner wrote — it
contains the tag's album when the tag was present at scan time,
otherwise the folder name. So whenever a tag was missing or
changed after scan, the folder name would win over the live
`album` column.

Fixed the priority by querying `album` first and falling back to
`album_group_title` only when the live metadata is empty / NULL /
'Unknown Album':

  COALESCE(
      NULLIF(NULLIF(TRIM(album), ''), 'Unknown Album'),
      album_group_title,
      'Unknown Album'
  )

Folder-grouped queries (which use `album_group_key` first and
intentionally prefer folder-derived titles) are unchanged.

Closes #411

cc
2026-05-12 18:10:59 -06:00
vicrodh cd7992119b Merge branch 'feature/discovery-v2-scaffold' into pre-release
AlbumCard migration to Discovery V2's AlbumCardLite + AlbumCardLibraryLite,
chunked LocalLibrary albums with backend pagination (incl. Plex
consolidation via ATTACH + UNION), Plex thumbnail transcoding,
recycling-pool perf experiments, scrollbar homologation, and the
inline-deleted legacy HomeView / ForYouTab / AlbumCard.svelte files.

Significant commits in this branch:

  - feat(local-library): chunked store + AlbumGridPool (Plex-style
    paginated virtualization with `v2_library_get_albums_page` +
    UNION ALL with `plex_cache.plex_cache_tracks`)
  - perf(plex): request 220x220 transcoded artwork (single biggest
    runtime perf win of the session)
  - feat(discovery-v2): ModalLite swept across all 22 modals
  - feat: AlbumCardLite + AlbumCardLibraryLite + AlbumGridPool
  - feat(views): migrated ArtistDetail, Search, Label, LabelReleases,
    Award, AlbumDetail, ArtistsByLocation, Favorites,
    VirtualizedFavoritesAlbumGrid, Purchases to the Lite variants

cc
2026-05-12 17:54:48 -06:00
vicrodh 185d5abdca fix(search): card width + quick-menu portal
Two related bugs surfacing after the AlbumCardLite migration in
Search > Albums:

1. `albumGridCols` over-computed the column count because the
   constant `ALBUM_CARD_WIDTH = 210` was stale — AlbumCardLite is
   actually 220px wide post-migration. The math thought N+1 cards
   fit per row when only N did, the last card per row wrap-around
   to a visual second line inside the same virtual-scroll-item,
   and visually piled on top of the next virtual row's cards.
   Bumped the constant to 220.

2. `AlbumQuickMenu` (the kebab popover) used `position: fixed` but
   was rendered inline inside the card, which lives inside the
   virtual scroller's `transform: translateY(...)` wrapper. Per the
   CSS spec, `position: fixed` resolves relative to the nearest
   ancestor with a transform, not the viewport — so the menu
   opened way off where the user clicked. Added a small `portal`
   action that appends the menu to `document.body` on mount,
   keeping it in viewport coordinates regardless of where the
   trigger lives in the tree.

Bonus: reverted the speculative `contain: paint` on `cover-wrap`
from 5045096e — that was attacking the wrong symptom (the user
reported the issue was rooted in #1 above, not in cover-image
clipping). `contain: paint` would add a per-card layer cost under
SW compositing without buying anything now that the real cause is
fixed.

cc
2026-05-12 17:51:06 -06:00
vicrodh 5045096e1c fix(album-card-lite): contain: paint on cover-wrap forces clip
Third pass at the SearchView > Albums overlap bug. `overflow: hidden`
+ `position: absolute; inset: 0` on the img weren't enough to keep
some artwork inside the 220x220 box — the `cachedSrc` action applies
`transform: translateZ(0)` to the img under HW acceleration (the
WebKit 2.50+ texture-eviction workaround) and the composited layer
that creates can escape the parent's clip rect in certain layouts.

`contain: paint` on the parent forces it to be a paint containment
boundary at the rendering-layer level — anything painted by
descendants, composited or not, gets clipped to this box.

cc
2026-05-12 17:43:52 -06:00
vicrodh de30ede4fc fix(album-card-lite): bound cover img to parent + delete dead legacy views
Two cleanups in one:

1. Cover img positioning. User reported some portrait-aspect album
   artwork in Search > Albums was visually escaping cover-wrap's
   `overflow: hidden` and bleeding upward into the row above. The
   img was `width: 100%; height: 100%; object-fit: cover` but in
   normal flow, while `cachedSrc` applies `transform: translateZ(0)`
   to it under HW acceleration (the WebKit 2.50+ texture-eviction
   workaround). Combined, the transform was lifting the img into
   its own composited layer in a way that escaped the parent's
   clip box for some configurations.

   Fix: `.cover` (and `.cover-placeholder`) are now
   `position: absolute; inset: 0`. Same containment pattern
   AlbumCardLibraryLite already uses; strictly bounds the painted
   image to the 220x220 cover-wrap box regardless of any transform
   the image carries.

2. HomeView.svelte and ForYouTab.svelte are deleted. They were
   legacy /home renderings that DiscoveryView (Discovery V2)
   replaced — `+page.svelte` only mounts DiscoveryView under
   `activeView === 'home'`, and the only remaining `HomeView` and
   `ForYouTab` references were the two files referencing each
   other. The legacy `AlbumCard.svelte` (used only by those two)
   goes with them. `AlbumMenu.svelte` stays for now — `AlbumDetailView`
   and `PlaylistDetailView` still use it for page-header menus.

cc
2026-05-12 17:39:18 -06:00
vicrodh 234af9d63a fix(search): align-items: flex-start + fixed wrapper width
User-reported visual bug after the AlbumCardLite migration: some
album covers in Search > Albums appeared to overlap into the row
above. The `.albums-grid-row` flex container used the default
`align-items: stretch`, which stretched each wrapper to the tallest
card in the row. With AlbumCardLite's optional Hi-Res pill the
rows had heterogeneous heights, and stretched wrappers let the
absolutely-positioned cover from neighbouring cards visually
encroach on previous rows.

Two clamps:
  - `align-items: flex-start` on `.albums-grid-row` — every card's
    cover top-aligns to the row baseline.
  - `width: 220px` on `.album-card-wrapper` (replaces the old
    `min-width: 160px`) — matches AlbumCardLite's intrinsic width
    exactly, no flex stretch room left.

cc
2026-05-12 17:31:15 -06:00
vicrodh 36b6a2ee2d feat(purchases): migrate AlbumCard → AlbumCardLibraryLite
Profile C path (purchased albums grid). Reused
`AlbumCardLibraryLite` rather than the full AlbumCardLite because
Purchases never needs favorites, the genre overlay, the menu, or
the artist link — it's a read-only grid of bought albums that
either click through (downloadable) or render as unavailable.

The legacy passed a formatted purchase date through the
`releaseDate` slot. AlbumCardLibraryLite doesn't surface a date
field; the purchase date is recoverable from the album detail
page, so the surface loses one secondary cue but stays
functional.

The remaining legacy `AlbumCard.svelte` callers are now only
`HomeView.svelte` and `ForYouTab.svelte`, both Discovery V2
replacement candidates that are slated for deletion once parity
is signed off.

cc
2026-05-12 17:08:01 -06:00
vicrodh d5d8d29dc4 feat(favorites): migrate AlbumCard → AlbumCardLite
FavoritesView (3 inline grid callsites, one per Discography / EPs &
Singles / Live Albums section per the in-favorites artist-detail
layout) and VirtualizedFavoritesAlbumGrid (one callsite inside the
virtualizer's grid-row branch) both swap to AlbumCardLite. Same
`makeArtistClickHandler` pattern as the other Profile B views.

Same prop drops + renames as before.

cc
2026-05-12 17:05:59 -06:00
vicrodh d02e5d38b7 feat(views): migrate Award + AlbumDetail + ArtistsByLocation to AlbumCardLite
Three more Profile B views swap to Discovery V2's AlbumCardLite:

  - AwardView: single discography grid; `makeArtistClickHandler`
    pattern for the per-album artist link.
  - AlbumDetailView: the Related Albums section renders cards for
    other albums by the same artist. `onArtistClick` closes over
    the page's main `album.artistId` (all related albums share
    it). All the relatedAlbum-prefixed handlers route through the
    Lite props with the same per-album closures.
  - ArtistsByLocationView: three identical-shape grids (Discography
    / EPs & Singles / Live Albums) — slim invocation, no menu
    actions, just `onClick` + `onPlay`.

Same prop drops + renames as the earlier migrations.

cc
2026-05-12 17:03:17 -06:00
vicrodh 79cee87038 feat(label): migrate AlbumCard → AlbumCardLite
LabelView (2 callsites: New Releases + Critics' Picks horizontal
rows) and LabelReleasesView (2 callsites: grouped + flat grids)
swap to Discovery V2's AlbumCardLite. Same `makeArtistClickHandler`
pattern as SearchView so the per-album artist id closes correctly
into the zero-arg `onArtistClick`.

Critics' Picks block in LabelView didn't pass the download/
isAlbumFullyDownloaded set originally — kept that gap (it's a
read-only editorial section, no offline-cache UX expected).

Same prop drops as the other Profile B migrations: `size`,
`releaseDate` → parsed `releaseYear`, `onOpenContainingFolder`,
`downloadStateVersion`. Renames: `onAddAlbumToPlaylist` →
`onAddToPlaylist`, `onclick` → `onClick`.

cc
2026-05-12 16:58:30 -06:00
vicrodh 550d75bbf0 feat(search): migrate AlbumCard → AlbumCardLite
Two callsites in SearchView (Albums tab + Best Match) swap to
Discovery V2's `AlbumCardLite`. Profile B differs from Artist
Detail in that albums DO have a clickable artist label — the Lite
component's `onArtistClick: () => void` takes no args, so a small
`makeArtistClickHandler(artistId)` factory closes over the album's
artist id at iteration time and produces the right zero-arg
handler (or `undefined` when the album lacks an artist id).

Same prop drops as ArtistDetailView: `size`, `searchId`,
`releaseDate` (replaced by parsed `releaseYear`),
`onOpenContainingFolder` (never rendered by legacy menu anyway),
`downloadStateVersion` (Svelte reactivity replaces it). Renames:
`onAddAlbumToPlaylist` → `onAddToPlaylist`, `onclick` → `onClick`.

cc
2026-05-12 16:55:22 -06:00
vicrodh 10e32aa058 feat(artist-detail): migrate AlbumCard → AlbumCardLite
Six callsites in ArtistDetailView (one per release-type section)
swap from legacy `../AlbumCard.svelte` to
`$lib/discovery-v2/AlbumCardLite.svelte`. AlbumCardLite gained the
`isAlbumFullyDownloaded` flag + `onReDownloadAlbum` callback this
session so the chunked path keeps the offline-cache UX legacy
provided.

Props dropped during migration (legacy artefacts AlbumCardLite
doesn't model and the legacy view didn't really need either):

  - `size="large"` — Lite is fixed 220x220, matches the legacy
    "large" size already.
  - `searchId` — data attribute used by search-result navigation;
    not applicable to discography views.
  - `releaseDate` — Lite shows `releaseYear` (number) instead of a
    formatted date string. Year flows through the artist subtitle
    slot the same way the legacy view did (`album.year || ''`),
    so the visible info is unchanged.
  - `onOpenContainingFolder` — the legacy AlbumMenu declared but
    never rendered an item for it; Lite mirrors that.
  - `downloadStateVersion` — Lite re-evaluates props natively
    through Svelte reactivity; the manual version bump isn't needed.

Renames: `onAddAlbumToPlaylist` → `onAddToPlaylist`,
`onclick` → `onClick` (Svelte 5 convention used across Discovery V2).

cc
2026-05-12 16:51:17 -06:00
vicrodh 447344c8c0 fix(local-library): plex id double-prefix + scrim z-index over buttons
Two bugs from the same session:

  - The plex_aggregated CTE prefixed `group_key` with `'plex:'`, but
    `plex/mod.rs::plex_album_key()` already returns `"plex:<hash>"`.
    Result: album.id was `"plex:plex:<hash>"` from the chunked path,
    which `handleAlbumClick` couldn't resolve, so opening an album
    showed NO TRACKS. Fixed: use `COALESCE(album_key, 'plex:' ||
    rating_key)` so populated rows pass through untouched and only
    the rating_key fallback gets the prefix added. GROUP BY mirrors
    the same expression for consistency.

  - On AlbumCardLibraryLite the scrim (`.cover-wrap::after`) and the
    overlay container (`.overlay`) were both at `z-index: 2`. Pseudo-
    elements paint after all DOM children at the same z-index, so
    the scrim ended up ON TOP of the buttons, hiding them on hover.
    Re-laid the layering so it's monotonic top-to-bottom:
        1  cover img
        2  scrim
        3  overlay (action buttons)
        4  select-checkbox + source-badge-slot

Plus: restored the dark `rgba(0,0,0,0.85)` hover scrim itself,
removed the `::after` accent-border ring + title-colour cue. Per the
2026-05-12 user call ("Hasta quisiera el overlay de regreso") — the
Plex thumbnail downscale (9f417999) made the scrim's opacity
transition cheap again since the underlying paint is now 220x220
not 1000x1000. Buttons go back to opacity 0 → 1 on hover with a
single transition on `.action-buttons`. White-outline circles
(transparent fill) since the scrim provides contrast.

cc
2026-05-12 16:40:45 -06:00
vicrodh 9f41799965 perf(plex): request 220x220 transcoded artwork for thumbnail contexts
Plex artwork URLs were going directly to `/library/metadata/<id>/thumb/<ts>`
which serves the **full resolution** image — typically 1000x1000 or
larger for album covers. The browser then had to decode the full JPEG
and downscale to the displayed 220x220, blowing through both decode
budget (full-res JPEG decode per visible card) and memory budget
(~4MB raw raster per card × N visible = hundreds of MB of image cache).

For a Plex-dominant library (the user's case), this was a hidden
cost factor on every thumbnail grid surface.

Fix: `buildPlexArtworkUrl` now takes an optional `size` parameter.
When provided, the path is wrapped in `/photo/:/transcode` so the
Plex server downscales server-side and the browser receives ~30KB
JPEGs at the right size instead of 1MB+ originals. When `size` is
omitted, the original full-res URL is returned — preserved for
`getFullArtworkUrl` (AlbumDetailView's big artwork display).

Updated callsites pass `220`:
  - LocalLibraryView `getArtworkUrl` (grid + list thumbs)
  - MixtapeCollectionDetailView resolve path
  - PlaylistDetailView track artwork

Per the 2026-05-12 user call: 220 over 440 — fluidity wins over
HiDPI sharpness on this surface.

cc
2026-05-12 16:33:28 -06:00
vicrodh 4bc1b0faea fix(local-library): hover ring above img, scrollbar height + style match
Three follow-ups from the 2026-05-12 user call:

  - Hover ring on AlbumCardLibraryLite was set up as a
    `.cover-wrap::after` pseudo with a transparent → accent border,
    but the cover `<img>` lives at `z-index: 1` while the pseudo
    inherited `auto` z-index — so the img painted on top of the
    ring. `z-index: 2` on the pseudo lifts it above the artwork.

  - Scrollbar in `.grid-pool` and `.virtual-container` was styled at
    10px wide with a non-standard track / thumb / hover palette,
    which read as a one-off vs the global `::-webkit-scrollbar`
    rule in app.css. Aligned to the app convention: 8px, transparent
    track, `var(--bg-tertiary)` thumb with `border-radius: 9999px`,
    `var(--text-muted)` on hover. `scrollbar-width: thin` for the
    standard property path.

  - `.album-sections.virtualized { height: calc(100vh - 280px) }`
    over-extended past the NowPlayingBar (104px) once the scrollbar
    was forced always visible — the gutter ran to viewport bottom
    instead of ending at the player. Bumped to `100vh - 360px` so
    the scroll area ends where the player starts.

cc
2026-05-12 15:32:57 -06:00
vicrodh 87d1692663 fix(local-library): visible scrollbars + list as default view
User reports neither the grid path nor the legacy virtualizer
(albums list, tracks, folders) show a vertical scrollbar in
LocalLibrary. The global `::-webkit-scrollbar` rule in app.css is
defined but doesn't appear to win against the overlay-scrollbar
behaviour some WebKitGTK builds default to.

Belt-and-suspenders on both scrollers (`.grid-pool` in AlbumGridPool
and `.virtual-container` in VirtualizedAlbumList):

  - `overflow-y: scroll` instead of `auto` reserves a permanent
    gutter and forces the bar to render even when the content
    barely overflows.
  - Standard `scrollbar-width` + `scrollbar-color` set, plus the
    `::-webkit-scrollbar*` pseudo-element rules with explicit
    thumb / track colours pulled from the theme. Different
    WebKitGTK configurations honour different sets, so we set
    both. 10px width so the bar is clearly visible (vs the 8px
    global default).
  - Hover state on the thumb shifts to `--text-primary` for
    affordance.

Bonus: `albumViewMode` now defaults to `'list'`. Per the same
user call, list mode runs acceptably under SW compositing while
grid still has the perf-cliff problem we're chasing. The toggle
is still in the toolbar so power users can flip it; persistence
is in-session only so the default reasserts on next launch.

Hover indicator on AlbumCardLibraryLite reworked: the previous
inset box-shadow was being painted under the absolutely-positioned
`<img>` so it never showed. Switched to a `.cover-wrap::after`
pseudo-element with a transparent border by default that flips to
`var(--accent-primary)` on `.card:hover`. The pseudo paints after
the image in DOM order, so it stays visible. Title still shifts
to the accent colour as a secondary cue.

cc
2026-05-12 15:25:12 -06:00
vicrodh 1aea33cf19 feat(local-library): Plex consolidation in chunked albums page query
Plex cache lives in a separate SQLite database
(`<data_dir>/qbz/plex_cache.db`), which is why `v2_library_get_albums_page`
previously only saw local_tracks and the LocalLibraryView chunked
path had to fall back to the legacy virtualizer when Plex was on.
That fallback meant Plex-dominant users (which is the common case)
got none of the chunked-store / recycling-pool perf work.

Now consolidated server-side via SQL `ATTACH DATABASE` + `UNION ALL`:

Backend (qbz-library):

  - `get_albums_metadata_page` accepts `plex_cache_path: Option<&Path>`.
    When the file exists it `ATTACH`es the plex DB to the connection,
    runs a CTE that aggregates `plex_cache.plex_cache_tracks` by
    `album_key` into the same column shape as local_aggregated, and
    UNIONs both before the filter/sort/pagination step. DETACHes at
    the end so subsequent queries don't see stale attachment.
    Plex group_key prefixed with `'plex:'` so it doesn't collide with
    local group keys; `'plex'` literal in the `source` column drives
    the frontend's SourceBadge mapping.
  - Schema differences smoothed via `CAST` in the plex arm:
    `duration_ms / 1000 → total_duration` (matches local seconds),
    `sampling_rate_hz → sample_rate` (INTEGER → REAL).
  - `count_albums_metadata_for_page` mirrors the same UNION so total
    is consistent across empty-page edge cases.

Backend (Tauri command):

  - `v2_library_get_albums_page` accepts an `include_plex` bool.
    When true, resolves the plex cache path the same way
    `plex/mod.rs::open_plex_cache_db` does and passes it through.

Frontend:

  - Chunked store's `LocalAlbumsQueryParams` carries an `includePlex`
    flag, included in the `paramsEqual` check so toggling Plex
    visibility invalidates the chunk cache.
  - LocalLibraryView drops the `!isPlexLibraryEnabled()` guard from
    both the `$effect` that calls `setParams` and the render
    condition that picks chunked vs legacy. The new path serves
    Plex-only, local-only, and mixed libraries the same way.

cc
2026-05-12 15:19:18 -06:00
vicrodh 2e58c889b1 fix(library-card): hover indicator uses inset box-shadow (not clipped)
Previous attempt used `outline` with positive `outline-offset` so the
ring sat in the gap outside the cover. The leftmost column has no
horizontal slack between the cover edge and the scroll container's
inner edge, so the outline was eaten on the left side of every card
in column 0.

`box-shadow: inset 0 0 0 3px var(--accent-primary)` paints the ring
INSIDE the cover-wrap's box. No layout reflow, no clipping concerns,
no transition — single instant paint on hover entry/exit. Title text
still flips to accent colour for a second cue.

cc
2026-05-12 15:09:57 -06:00
vicrodh 9c2ce89412 style(legacy-grid): match Discovery 32px gap in VirtualizedAlbumList
GRID_GAP was 22px and album-grid-row CSS was 22px gap. Discovery V2
uses 32px. Library users with Plex enabled stay on the legacy grid
(per the temporary fallback in 7508c3cd) and saw the smaller gap as
the user-reported "20px separación entre columnas". Aligning to
32px matches Discovery and matches the AlbumGridPool default.

cc
2026-05-12 15:05:51 -06:00
vicrodh 478582e24b style(library-card): hover outline + 220px card width to match Home
User wanted a hover indicator without bringing back the per-frame
opacity transition that was the scroll-perf bottleneck. CSS `outline`
fits: paints outside the element's box (no layout reflow), no
transition, single paint event on hover entry/exit. Uses
`var(--accent-primary)` solid, no transparency, no animation per
user's "sin animación ni transparencia" follow-up.

Bumped the card cover from 210x210 to 220x220 so Library matches
Discovery V2 `AlbumCardLite` width — with the same 32px gap on both
sides, the column step is now identical and the two views share
one visual rhythm. Updated `VirtualizedAlbumList`'s
`GRID_MIN_CARD_WIDTH` and `GRID_ROW_HEIGHT` so the legacy grid path
(used when grouping or quality filters are on) stays consistent
with the chunked path.

cc
2026-05-12 14:55:13 -06:00
vicrodh 820142055d style(grid-pool): bump card gaps to 32px to match Home visual rhythm
Library was rendering at 22px horizontal / 24px vertical between
cards while Home (DiscoverySection) used 32px in both axes. Reading
the same artist library across the two views felt like two different
densities. Aligning to 32/32.

cc
2026-05-12 14:49:52 -06:00
vicrodh 1a5131ba01 style(library-card): drop solid theme buttons for white outline + shadow
User feedback: the solid theme-coloured buttons looked "horrible"
against the artwork. Replaced with the same white-outline circles
that Home AlbumCardLite uses, plus a static `box-shadow` under each
button and a small `drop-shadow` on the icon for legibility on light
covers. Cost is a one-time paint per slot rebind, not the per-frame
re-paint the transitioning overlay caused — so the visual contrast
guarantee comes back without restoring the perf regression.

Also bumped buttons further toward the bottom edge (padding-bottom
44px → 12px).

cc
2026-05-12 14:48:48 -06:00
vicrodh 55a139dab0 style(library-card): align action buttons at bottom 44px (matches Home Lite)
Buttons used to sit center-vertically of the 210x210 cover; user
asked for the Home AlbumCardLite Y position (44px clearance above
the bottom edge). Done with flex `align-items: flex-end` +
`padding-bottom: 44px` rather than `position: absolute; bottom: 44px;
transform: translateX(-50%)` — avoids any transform on the child,
which under SW compositing is one fewer hint for WebKit to maybe
allocate a separate framebuffer over.

cc
2026-05-12 14:43:31 -06:00
vicrodh 694423c704 perf(library-card): drop hover entirely, theme-coloured persistent buttons
Direct attack on the residual scroll-time cost under software
compositing. The user-reported pain ("el mouse over tarda en
reaccionar") is hit-testing: every pointermove during a mouse-wheel
scroll forces WebKit to walk every visible hit target, and with
~50 cards × multiple nested elements per card (cover-wrap, image,
placeholder, overlay, source-badge, buttons) the cascade was visible
as hover delay and scroll lag. Per the 2026-05-12 user call, this is
a palliative — the underlying SW-comp hit-testing cost stays — but
removes the bulk of the hot path.

Two changes:

  - `.cover-wrap { pointer-events: none }` opts the decorative
    children out of hit-testing. Mouse moves over the cover fall
    through to `.card` (which carries `role="button"` and the click
    handler) without WebKit needing to consider each of the 5+
    nested elements as hit candidates. `.action-buttons` re-enables
    pointer-events on itself so the buttons still receive clicks.

  - Action buttons are now permanently visible (no `opacity: 0 → 1`
    on hover, no transition). Buttons use theme variables —
    `var(--bg-primary)` background + `var(--text-primary)` icon —
    so contrast holds on any cover artwork without needing the
    artwork-palette sampling the legacy AlbumCard did. The primary
    play button keeps `var(--accent-primary)` for visual hierarchy.

cc
2026-05-12 14:39:51 -06:00
vicrodh 36ce669c45 perf(grid-pool): revert transform + contain, drop bufferRows 2→1
CPU-mode CSV from the 2026-05-12 measurement window showed mean CPU%
regressing run-over-run since the chunked-store landing:

  8f2cd965 (chunked + top/left, no contain)  mean 62
  a3f1426c (transform on slot)               mean 172
  9003d93a (+ contain layout paint)          mean 176

Theory for the regression: under software compositing every "layer
hint" (`transform`, `contain: paint`, `will-change`) forces WebKit
to allocate a per-slot CPU framebuffer. ~50 framebuffers composited
per frame in CPU dwarfs the layout work the hints were supposed to
skip. Under HW comp those would be GPU layers (cheap); the SW path
isn't symmetric.

Reverts:
  - `transform: translate(...)` → inline `top: Npx; left: Mpx`.
  - drops `contain: layout paint` from `.grid-slot`.

Keeps:
  - drop scrim (f494b1e6) — user explicitly accepted; cheap visual
  - skeleton placeholder (9003d93a) — visual-only, no perf cost
  - chunked store (8f2cd965) — the actually-confirmed win

Also drops `bufferRows` from 2 to 1, taking pool size from ~63 to
~49 cards. Chunk pre-fetch lead time is still ample (100 albums per
chunk ≈ 14 rows).

cc
2026-05-12 14:21:30 -06:00
vicrodh 9003d93a6c perf(grid-pool): drop bufferRows 3→2 + skeleton placeholder
Two complementary changes to the chunked LocalLibrary grid:

  - `bufferRows = 2` in AlbumGridPool. With ~7 cols + ~5 visible
    rows, pool size drops from 77 to 63 cards (-18%). Under software
    compositing each rendered card carries per-frame paint and hit-
    testing cost, so trimming buffer is a direct win. Chunk pre-
    fetch lead time is unchanged in practice — chunks are 100 albums
    = ~14 rows, so even bufferRows=1 would give ample lead.
  - `renderPlaceholder` snippet wired up in LocalLibraryView. When a
    slot maps to an index whose chunk hasn't arrived, the pool now
    renders a static skeleton (cover block + 3 text bars) instead
    of leaving the slot blank. No shimmer, no animation — shape
    alone signals "loading". Matches the loaded card dimensions
    so binding the real data later doesn't shift layout.

cc
2026-05-12 14:13:22 -06:00
vicrodh f494b1e6e8 perf(library-card): drop full-cover hover scrim, keep floating buttons
Per the 2026-05-12 user call ("para localLibrary podría aceptar
quitarle el overlay y solo dejar los botones, que al final son
funcionalidad"), the dark `rgba(0,0,0,0.85)` overlay that filled the
entire 210x210 cover on hover is gone. Each card's hover state now
fades only the 3 action buttons (play + kebab) in over 120ms; the
artwork stays fully visible underneath.

Why this matters under CPU compositing: every pointer-traversal of a
card during a mouse-wheel scroll triggered a paint of the full
overlay area (44K pixels per card alpha-blended over the cover).
Multiplied by ~80 cards in the pool and a continuous pointermove
stream, the paint cost dominated frame time. With the scrim gone,
the per-card hover paint is bounded to ~44x44 px per button
backdrop — a 30x reduction.

Buttons now carry their own `rgba(0,0,0,0.55)` backdrop so they stay
readable on light cover art. Hover boosts that to 0.8. Same visual
hierarchy as before (play primary in white, kebab outlined), just
without the curtain over the artwork.

cc
2026-05-12 14:07:06 -06:00
vicrodh 7d26522fc9 perf(grid-pool): contain: layout paint on each slot
The transform-only positioning eliminated scroll-tick layout for slot
position updates, but the perf trace still showed 1s+ layout spikes
triggered by pointermove during scroll. Root cause: WebKit's hit-
testing on each pointer event re-evaluates layout across the whole
grid because slot boundaries aren't isolated. `contain: layout paint`
on each slot tells WebKit the slot's layout and paint are independent
of the rest, so a hover-state change (or any internal mutation) in
one slot can no longer cascade to its 79 neighbours.

cc
2026-05-12 14:05:51 -06:00
vicrodh a3f1426cfc perf(grid-pool): position slots with transform instead of top/left
The Web Inspector perf trace showed scroll-time was dominated by
layout, not script or paint. Single layout records ran 750ms-1.5s
each, triggered by scroll → animation-frame → slot position update.
Each slot's `style="top: Npx; left: Mpx"` mutation invalidates its
own layout box, and with 80 slots repositioned per scroll tick the
cascade became the frame.

`transform: translate(x, y)` on the same absolutely-positioned slot
goes straight to paint/composite and skips layout entirely.
Reported even under software compositing the GPU-layer cost is
negligible compared to the layout work it eliminates.

cc
2026-05-12 13:48:01 -06:00
vicrodh 7508c3cd18 fix(local-library): fall back to legacy path when Plex is enabled
The chunked grid silently hid every Plex album because
`v2_library_get_albums_page` only queries `local_tracks`. Plex cache
lives in a separate SQLite database (`plex_cache.db`) and the
proper consolidation needs `ATTACH DATABASE` + `UNION ALL` from the
same aggregation, which is the next delta.

Until that lands, gate the chunked path on `!isPlexLibraryEnabled()`
so Plex users keep seeing their full library through the legacy
`VirtualizedAlbumList` (slower under CPU compositing but
functionally complete). Pure-local users still get the chunked
benefit.

cc
2026-05-12 13:39:27 -06:00