Commit Graph
100 Commits
Author SHA1 Message Date
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
vicrodh d44ee7c086 build(flake): bump qbz to v1.2.12
cc
2026-05-09 19:14:45 -06:00
vicrodh e920c350c4 Merge pre-release into main for v1.2.12 hotfix release 2026-05-09 18:14:44 -06:00
vicrodh 19c2912851 chore(snap): bump source-tag to v1.2.12
Was lagging at v1.2.10 since the v1.2.10 release; v1.2.11 missed it.
Snap CI builds from this tag, so it must match the version field.

cc
2026-05-09 18:12:15 -06:00
vicrodh 0c9e4ee5fc chore(release): bump version to 1.2.12
Hotfix release. Reverts titlebar-mode-selector and PR #406 (broken
window chrome on Linux); preserves PR #407 (sticky home header) and
adds PR #408 (macOS native icon and tray variants by @Vudgekek).

cc
2026-05-09 18:11:24 -06:00
vicrodh 2fdec2ca57 Merge pull request #408 from Vudgekek/feature/external/macos-qol
feat(style): add macOS native app icon and tray icon variants
2026-05-09 18:06:27 -06:00
vicrodh f1ef161560 Merge branch 'bugfix/titlebar-rollback-1.2.12' into pre-release
v1.2.12 hotfix: revert the titlebar-mode-selector merge and PR #406
which together caused all titlebar modes to render incorrectly in the
production binary (empty top space, gap above now-playing bar). The
v1.2.10 model (use_system_titlebar + hide_title_bar booleans) is
restored; Plasma mode is dropped pending a less brittle redesign.

PR #407 (sticky home header fix) is preserved.
2026-05-09 18:06:14 -06:00
vicrodh ee54c49aef revert: roll back PR #406 (linux-titlebar-draggable)
Reverts merge f7623154 which built macOS support on top of the variant
rendering infrastructure introduced by titlebar-mode-selector. With the
variant system gone (previous revert), PR #406's changes (class:macos,
stripped strip with traffic-light reservation, macOS-aware drag bands)
reference dead architecture.

Most of PR #406 was already removed by the previous revert via file
checkouts; this commit cleans up the residual macOS-specific cosmetics
in Sidebar.svelte and +page.svelte (padding-top 36→32, drag-region
height 32→28, macos main-content padding restored to generic rule).

PR #407 (sticky home header fix, ac627d43) is preserved — independent
from titlebar work, only touches HomeView.svelte.

cc
2026-05-09 17:49:07 -06:00
vicrodh e3bdbc9ab4 revert: roll back titlebar-mode-selector merge
Reverts merge 1be46f1a which introduced the 4-mode titlebar enum
(qbz/system/plasma/hidden), variant-aware rendering (full/stripped),
GDK_BACKEND=x11 coercion for Plasma, and per-mode mounting in +page.

The "stripped strip mounted alongside Plasma SSD" architecture caused
all titlebar modes to render incorrectly in the production binary —
empty space at top, separation gap above the now-playing bar, controls
visible without a backing strip. Reverting restores v1.2.10 behavior:
two booleans (use_system_titlebar + hide_title_bar), no variants, no
GDK coercion. Plasma support is dropped pending a less brittle redesign.

Conflicts in SettingsView.svelte resolved by taking the pre-merge side
for the four titlebar UI blocks; non-titlebar additions in this cycle
(Discord RPC settings, reserve-DAC, offline cache nav) preserved.

cc
2026-05-09 17:48:37 -06:00
vicrodh f65248a917 build(flake): bump qbz to v1.2.11
Update qbzVersion + srcHash + npmHash for the v1.2.11 tag (commit
cc98b7f7). Hashes computed via 'nix-prefetch-url --unpack' and
'prefetch-npm-deps package-lock.json' against the tagged tree.

cc
2026-05-09 16:36:58 -06:00
vicrodh cc98b7f7e6 docs(release-1.2.11): correct PR attribution for sticky home header (#406 → #407)
PR #406 was the macOS titlebar unification (mentioned separately in the
Title bar section); the sticky home header pop-in fix is PR #407. Both
from @afonsojramos in the same merge round.

cc
2026-05-09 15:51:07 -06:00
vicrodh fd7a6e2f89 docs(release): add 1.2.11 TAG_DETAILS and metainfo entry
cc
2026-05-09 14:33:11 -06:00
vicrodh ea6167babb chore(release): bump version to 1.2.11
cc
2026-05-09 14:33:11 -06:00
vicrodh 667153624f chore(about): bump codename to Exclusive Hardening, add Vudgekek and DoubleGate
cc
2026-05-09 14:33:11 -06:00
Victor RH 08acd304a1 Merge pull request #407 from afonsojramos/fix-glued-top-animation
fix(home): unify sticky tab header to avoid pop-in animation
2026-05-09 12:45:31 -06:00
Victor RH f7623154f4 Merge pull request #406 from afonsojramos/linux-titlebar-draggable
feat(titlebar): unify macOS layout with Linux qbz titlebar
2026-05-09 12:45:28 -06:00
vicrodh a8b5d3e8f5 Merge branch 'feature/internal/discord-rpc-flatpak-symlinks' into pre-release
Discord RPC now reaches Discord-Flatpak's IPC socket without making the
integration opt-out at the sandbox layer. Bridge symlinks are created
lazily on first connect after the user enables the toggle; users who
never enable Discord RPC see no sandbox changes.

* prepare_flatpak_discord_socket_links in discord_rpc.rs runs from the
  lazy connect path, gated on FLATPAK_ID env var
* Manifest gains xdg-run/app/com.discordapp.Discord:create filesystem
  permission, kept alongside the existing static path
2026-05-09 12:38:25 -06:00
vicrodh e2e4fb8cab feat(discord-rpc): lazy Flatpak socket bridge keeps the integration opt-in
Discord-Flatpak writes its IPC socket to
\$XDG_RUNTIME_DIR/app/com.discordapp.Discord/discord-ipc-N rather than
the \$XDG_RUNTIME_DIR/discord-ipc-N path the discord-rich-presence crate
searches by default. The naive way to bridge that gap — a startup
wrapper that unconditionally symlinks every iteration before the binary
launches — works, but it pulls the integration off-script: every other
optional integration in QBZ (Last.fm, ListenBrainz, MusicBrainz, Plex)
is opt-in, with zero telemetry and zero filesystem reach until the user
flips the toggle. A startup wrapper would silently turn Discord RPC
into opt-out at the sandbox layer; the symlinks would exist for users
who never touched the integrations panel.

Move the bridge into the lazy connect path instead. The first time the
user actually connects — i.e. the first track plays after they enabled
the toggle — prepare_flatpak_discord_socket_links runs:

* No-op outside Flatpak (no FLATPAK_ID env var)
* No-op when \$XDG_RUNTIME_DIR/discord-ipc-N already exists (native
  Discord, or a previous lazy run in this Flatpak instance)
* No-op when the Discord-Flatpak source path doesn't exist (Discord
  not running)
* Otherwise, symlinks 0..9 from \$XDG_RUNTIME_DIR/discord-ipc-N to
  \$XDG_RUNTIME_DIR/app/com.discordapp.Discord/discord-ipc-N

Symlinks die with the Flatpak instance — \$XDG_RUNTIME_DIR is per-
instance, so there's no cross-session pollution. Users who never enable
Discord RPC keep the same sandbox surface as before.

Manifest: keep the static xdg-run/discord-ipc-0 permission for native
Discord, add xdg-run/app/com.discordapp.Discord:create so the lazy
symlink path is allowed when the user opts in. The two coexist; the
crate's default search path covers Discord-native, the symlinks cover
Discord-Flatpak.

cc
2026-05-09 12:38:25 -06:00
vicrodh 0274f2b030 chore: clean svelte-check warnings (CSS, a11y, types)
Drains the wiki "23 warnings" baseline back to zero so future regressions
stand out:

* Drop 16 unprefixed \`app-region\` CSS lines across TitleBar, TitleBarNav,
  ImmersiveHeader, ImmersivePlayer, MiniPlayerFooter and MiniPlayerWindowControls.
  Tauri's webview is WebKit / Chromium on every platform we ship to, so
  \`-webkit-app-region\` already covers the chrome-drag region; the
  unprefixed property only existed to keep stylelint quiet but the CSS
  spec doesn't define it and svelte-check warns on every site.
* Remove 2 empty rulesets in AlbumCreditsModal and NowPlayingBar (leftover
  comment-only blocks from earlier refactors).
* Pair \`-moz-appearance: textfield\` with the standard \`appearance: textfield\`
  in LocalLibraryTagEditorModal so the rule no longer trips the
  property-companion warning.
* Delete the unused \`.quality-text\` selector in NowPlayingBar.backup.svelte
  (file is a frozen snapshot of an older NPB iteration; the live NPB
  doesn't use that class).
* Annotate Tooltip.svelte and immersive/panels/VinylPanel.svelte with
  \`svelte-ignore a11y_no_static_element_interactions\` plus a comment
  explaining the context — both are decorative wrappers (a tooltip hit
  area; a 3D tilt effect on artwork) where adding an ARIA role would
  misrepresent the semantics.
* Add \`@types/node\` as a devDependency (^22) so the tsconfig resolution
  for the SvelteKit-extended config stops complaining about the missing
  type definition.

cc
2026-05-09 09:46:43 -06:00
Victor RH 055b76bfbe Merge pull request #403 from vicrodh/dependabot/cargo/src-tauri/tauri-2.11.1
chore(deps): bump tauri from 2.10.3 to 2.11.1 in /src-tauri
2026-05-09 09:25:48 -06:00
Victor RH 55f6acb17a Merge pull request #404 from vicrodh/dependabot/cargo/src-tauri/openssl-0.10.79
chore(deps): bump openssl from 0.10.78 to 0.10.79 in /src-tauri
2026-05-09 09:25:25 -06:00
vicrodh 82173b78c2 Merge branch 'feature/internal/ephemeral-folder' into pre-release
Open Folder — play music ad-hoc without adding to library.

* Backend: ephemeral_library module (in-memory HashMap, synthetic
  high-positive ids, CUE support, format whitelist, artwork cache,
  gapless prefetch).
* Frontend: Open Folder button in Folders > Tree toolbar; pane mirrors
  album-detail layout with cover-art per group + disc sections.
* Persistence: folder path survives restart (rehydrated on mount); the
  ephemeral data itself is rebuilt fresh.
* Lifecycle cleanup: queue + NowPlayingBar wiped on broom click /
  navigation away / inaccessible folder.
* Playback: gapless across virtual track boundaries (CUE-FLAC) with
  position offset translation so the seekbar reads in virtual time.
* Action gating: favorite + playlist + track info disabled (not removed)
  for ephemeral tracks across NowPlayingBar, QueuePanel, immersive.
* Folder-tree sidebar polish: search right-aligned, default 290px width.
2026-05-09 09:24:51 -06:00
vicrodh 2d33d7cb42 feat(player): disable metadata actions for ephemeral tracks
Library-bound actions (favorite, add-to-playlist, track info) require a
DB row to operate; ephemeral tracks live only in memory and would 404
on use. Surface them disabled rather than removed so the affordance
stays discoverable in the chrome — users still see the icon, the
tooltip explains why it's inert.

Surfaces wired
- NowPlayingBar: + and ♥ disabled when active track id >= 2^48.
- QueuePanel: now-playing ♥ + per-track Add to Playlist + Track Info
  in both queue and history menus disabled per-row by id range.
- ImmersiveControls + PlayerControlsCompact: ♥ disabled.
- ImmersivePlayer: passthrough prop to PlayerControlsCompact.
- MiniPlayerFooter / MiniPlayerWindowControls: no metadata actions in
  these surfaces, nothing to gate.

Detection is by id range (>= EPHEMERAL_ID_FLOOR), not by source string.
The setQueueForLocalTracks mapping rewrites `source: 'ephemeral'` to
`'local'` before the track reaches the queue, so a string-equality
check would never fire — id range matches the rest of the ephemeral
codebase (gapless carve-out, CUE boundary, seek offset, queue/now-
playing wipe).

Tooltip on the disabled state uses the new
`actions.unavailableForEphemeral` key, translated for en, es, de, fr, pt.

cc
2026-05-09 09:24:37 -06:00
vicrodh 0fe19dd460 fix(player): seekbar + art + quality badge for ephemeral CUE virtual tracks
Three coupled visual bugs that surfaced once the queue advance and
gapless wiring started landing virtual track changes correctly.

Seekbar (case A: manual click / next button)
- PlayingTrack gets cueStartSecs / cueEndSecs fields and a
  patchCurrentTrackCueInfo() setter on playerStore. handlePlaybackEvent
  subtracts cueStartSecs from event.position when computing the
  displayed currentTime, so a virtual track that lives at e.g. offset
  7:26 inside its FLAC reads 0:00 at frame zero instead of 7:26 / 0:00.
- LocalLibraryView keeps an $effect on activeTrackId that looks up the
  matching ephemeral track and patches the cue info into playerStore
  every time the active track flips. Non-CUE / non-ephemeral tracks
  fall through with null/null which clears any prior boundaries.

Seekbar (case B: CUE boundary auto-advance)
- handlePlaybackEvent gains an "underlying CUE" branch: when event.track_id
  is in the ephemeral range AND currentTrack is in the ephemeral range
  AND currentTrack has cueStartSecs, the strict track_id match check is
  bypassed and the same offset translation runs. This keeps the seekbar
  ticking in virtual-track time after the boundary watcher swaps the
  displayed metadata while the audio buffer is unchanged. EPHEMERAL_ID_FLOOR
  is promoted to a module constant so both branches share it.

Album art (manual next button)
- queueArtwork.resolveQueueTrackArtwork was double-converting Tauri
  asset:// URLs through convertFileSrc, producing a 403 path
  (asset://localhost/asset%3A%2F%2F...). Add an asset: passthrough
  alongside http(s) and file:// so URLs already resolved upstream
  (LocalLibraryView's queue setter pre-converts via getArtworkUrl)
  reach the NowPlayingBar intact.

Quality badge ("audio downgrade")
- Boundary advance's setCurrentTrack call was passing LocalTrack.sample_rate
  (Hz) into PlayingTrack.samplingRate (kHz). The badge formatted as
  "FLAC 16/44100" instead of "FLAC 16/44.1", reading as a quality drop
  vs. the album list. Divide by 1000 to match the rest of the
  PlayingTrack contract.

cc
2026-05-09 07:51:47 -06:00
vicrodh 6d958fc659 feat(local-library): ephemeral pane iteration + folder-tree sidebar polish
A consolidated UI commit covering the LocalLibrary pane work from this
session. Backend additions land in earlier commits; this one is the
frontend surface plus the i18n strings.

Folder-tree sidebar polish (Folders > Tree)
- Search input pushed to the right edge via margin-left:auto so the
  action icons stay anchored on the left.
- FOLDER_TREE_SIDEBAR_DEFAULT_WIDTH and *_MIN both bumped to 290px (was
  302/200) — matches the user-tuned width and sets a coherent floor for
  the resize handle.

Ephemeral pane render
- Group tracks by album_group_key and render an album-style block per
  group: 96px cover, title/artist/year/quality/track-count, per-album
  Play button. Discs render as section headers when an album has more
  than one. CUE-derived virtual tracks slot into the same structure
  (they share the underlying FLAC's group key).
- Single-album sessions hide the per-album play button — it's redundant
  with the folder-level "Play all" header. Multi-album sessions surface
  it so each album has its own affordance.
- Loading spinner replaces the empty-pane state during scan; on slow
  NAS folders the wait used to be silent.
- Close button changed to the BrushCleaning icon ("Clear session" /
  "Limpiar sesion") — the X read as "close window" rather than "wipe
  this transient state". Strings updated in en, es, de, fr, pt.

Ephemeral session lifecycle
- Folder path persists to user storage; rehydrateEphemeralFolder reopens
  it on mount as long as the directory still exists. A `rehydrating`
  flag suppresses the auto-close $effect during the rehydration window
  so a delayed result doesn't get clobbered by a concurrent navigation.
- wipeEphemeralPlaybackArtifacts clears the queue + now-playing slot
  when the active or pinned track is ephemeral. Detection consults both
  activeTrackId AND playerStore.currentTrack — sometimes the track is
  visually pinned without playback being active (e.g. after session
  restore). Backend stop+clear doesn't reset current_track_id at the
  player level, so the frontend explicitly nullifies currentTrack to
  flush the NowPlayingBar chrome.
- Closing/replacing an ephemeral session triggers the wipe; navigation
  to a tree folder or album also triggers it via the watch-effect.

Ephemeral playback flow
- Queue scope is now the album the played track belongs to, not the
  whole folder. Folder-level "Play all" still queues everything in
  album-then-disc-then-track order; track-row clicks queue just the
  album. Per-album play button matches.
- Loading toast (buffering type) shows immediately on play to cover the
  multi-second file read off NAS. Dismissed when v2_library_play_track
  resolves.
- CUE virtual-track boundary advance: a raw playback:state listener
  watches absolute FLAC position and, when the displayed CUE track's
  cue_end_secs is crossed, calls v2_next_track + setCurrentTrack(next)
  + syncQueueState. Audio is unchanged (continuous decode of the same
  FLAC); only the chrome flips. Seekbar resets to 0:00 because the
  player's track_id stays pinned to the first virtual track — known
  limitation queued for a follow-up that virtualises position display
  via cue_start_secs offsets.

cc
2026-05-09 05:16:01 -06:00
vicrodh 53edfc3651 feat(player): ephemeral-aware gapless detection + session-restore filter
Two cross-cutting plumbing changes the ephemeral folder pane needs to
behave correctly without touching the rest of the playback paths.

playerStore: gapless transition detection used to require event.is_playing
to be true at the moment the track_id changes. The ephemeral file-data
swap (one buffer of bytes replacing another) briefly reports is_playing
false during the engine restart, and that transient frame arrives BEFORE
the player can flip the flag back. Without a carve-out the event was
mis-classified as an external track change, the queue's current_index
hadn't advanced (it's a separate state machine), and the UI froze on the
previous track. Streaming playback paths don't see this race because
their buffer transition keeps is_playing pinned. Carve-out is scoped to
event.track_id >= EPHEMERAL_ID_FLOOR so Qobuz/Plex/library are untouched.

routes/+page.svelte: session save now strips ephemeral tracks from the
queue snapshot and clears current_index if it pointed at one. Ephemeral
ids aren't stable across processes (the cache rebuilds from scratch on
each launch), and even if they were, the playback path 404s if session
restore fires before the folder rehydrates. The folder-path persistence
already brings the pane back; the queue can come back via re-clicking.

Also: gapless transition handler logs the advance/expected ids and the
match flag, plus runs a defensive syncQueueState on success — that's
belt-and-suspenders for paths where the queue's view of "current" lags
the player's gapless transition by a tick (e.g. ephemeral, where the
queue advance and the player swap are independent state machines).

cc
2026-05-09 05:14:14 -06:00
vicrodh ca17ad4da0 feat(ephemeral): CUE support, format whitelist, artwork cache, gapless prefetch
Backend additions for the ephemeral folder feature, all gated behind the
existing track_id >= EPHEMERAL_ID_FLOOR discriminator so library/Qobuz
paths stay untouched.

CUE handling
- Iterate scan.cue_files alongside audio_files. Parse via CueParser,
  resolve + canonicalize the referenced audio file, extract properties +
  format via MetadataExtractor, expand into virtual tracks via
  cue_to_tracks. The referenced audio file is added to a HashSet so the
  audio_files loop skips it (otherwise we'd list both N CUE-derived
  tracks and a single-row entry for the underlying FLAC).
- Whitelist of decoder-friendly extensions (flac/mp3/m4a/alac/wav/aiff/aif).
  Symphonia can't decode APE or raw BIN — those used to ride through
  silently and produce white noise on play; now they're skipped with a
  warn log and counted in skipped_files.
- v2_library_play_track ephemeral branch now honours track.cue_start_secs
  with a post-play_data seek, mirroring the regular local-file branch.

Artwork perf
- Cache extracted artwork by album_group_key. Most CUE-album folders
  share one cover across every virtual track, so calling extract_artwork
  + generate_thumbnail 155 times for a 155-track album was wasted work
  (the bottleneck behind the prior ~2-minute opens). The folder-level
  cache is kept as a secondary fallback for find_folder_artwork lookups.
- Skip standalone APE files entirely from the ephemeral pane — Symphonia
  can't decode them so showing them as a row that explodes on click is
  worse than not showing them at all. Counted in skipped_files.

Gapless
- v2_play_next_gapless gets an ephemeral branch at the top: track ids
  in the high range resolve through EphemeralLibraryState and feed the
  player.play_next path directly. Without this, gapless prefetch always
  reported "not in any cache" because the lookup keyed off DB rows.
- CUE-derived ephemeral tracks (cue_start_secs is Some) return false
  from the gapless branch — they share an audio file, so a play_next
  swap with no seek would start the next virtual track from the FILE's
  beginning instead of the CUE offset.

cc
2026-05-09 05:13:53 -06:00
vicrodh 9c745e595c fix(local-library): use high-positive ids for ephemeral tracks (queue/context u64 compat)
The original design used negative i64 ids to distinguish ephemeral
tracks from DB rows, but the queue and playback-context commands
serialize ids as Vec<u64> end-to-end (V2QueueTrack, v2_set_playback_context).
serde rejects negative numbers at the boundary, so setQueueForLocalTracks
and setPlaybackContext failed the moment the user clicked an ephemeral
track:

    invalid value: integer `-1`, expected u64

Switch synthetic ids to a high-positive range (EPHEMERAL_ID_FLOOR =
1<<48) instead. Pros over the negative scheme:
* Valid u64 — passes through the queue/context commands untouched
* Below 2^53 so JS Number round-trips without precision loss
* Far above any realistic DB autoincrement id, so the discrimination
  test (`track_id >= EPHEMERAL_ID_FLOOR`) stays unambiguous

Also fixes the snake_case mismatch on EphemeralFolderState — the Rust
struct serializes with its native field names (folder_path,
skipped_files), but the TypeScript type was declared in camelCase, so
ephemeralFolder.folderPath was undefined at render time.

cc
2026-05-09 02:36:55 -06:00
vicrodh aa015829aa feat(local-library): ephemeral folder playback (no DB persistence)
Adds an "Open Folder" button in the LocalLibrary > Folders tree toolbar
that lets the user point QBZ at any folder on disk and play it without
adding anything to local_tracks. Lets the app act as an ad-hoc media
player for downloaded albums, external drives, etc.

Backend:
- New ephemeral_library module: in-memory HashMap<i64, LocalTrack> keyed
  by synthetic negative ids. Reuses LibraryScanner and MetadataExtractor
  to walk the folder and build LocalTrack records.
- Three V2 commands: v2_ephemeral_open_folder (scan + populate, replaces
  any prior session), v2_ephemeral_clear, v2_ephemeral_get_track.
- v2_library_play_track gets a small branch at the top: track_id < 0
  resolves through the ephemeral state and goes straight to the regular
  local-file playback path (read file, hand to player). DB-backed paths
  (Qobuz cache, CUE start positions) are skipped — ephemeral tracks
  can't be any of those.

Frontend:
- "Open Folder" button (FolderOpen icon) next to the collapse-all button
  in the tree toolbar. Click opens the OS file picker, the result lands
  in a new ephemeralFolder $state.
- Right pane gets a new branch above the existing album/folder views:
  when ephemeralFolder is set, render a dedicated pane with header
  (folder name, path, track count, Play All, Close) and a flat track
  list with play-on-click rows.
- Lifecycle per spec: a session survives until (1) a different album or
  tree folder is selected — handled via $effect that watches
  selectedAlbum and selectedFolderPath, (2) the user clicks the Close
  button, or (3) the backend reports the file/directory is gone (caught
  in the play handler and folded into the same close path).
- Translations for en, es, de, fr, pt.

Notes:
- The audio_cache key is `track_id as u64`, which for negative ids wraps
  to a large u64 value — unique and safe (DB ids are positive).
- Edit-metadata gating for ephemeral tracks is deferred: there is no
  edit trigger from the new pane yet, so the sidecar/direct toggle
  behaviour is unchanged. Will gate when an edit path lands.

cc
2026-05-09 02:13:44 -06:00
vicrodh 4454619371 Merge branch 'feature/internal/sleep-timer' into pre-release
Sleep timer for the QueuePanel toolbar (issue #402).

* Frontend-only timer that hard-stops via cmdPause on expiry
* Presets 30 min / 1 / 2 / 3 / 5 hr plus Custom (1-1440 min)
* Green countdown beside the button when active; click to edit/cancel
* Smart-positioned popover (viewport-aware, recomputes on scroll/resize)
* Restrained type scale to keep visual hierarchy harmonized
* Translations for en, es, de, fr, pt
2026-05-09 01:11:54 -06:00
vicrodh 22687e7805 chore(player): lower sleep timer custom minimum to 1 min
15-minute floor was arbitrary; the user wants the lower bound at 1 minute
for short audio-segment workflows. Constant change propagates to both the
store's clamp and the input's min attribute.

cc
2026-05-09 01:11:53 -06:00
vicrodh 4d2b59eb18 fix(player): smart-positioned sleep timer popover with refined typography
Two issues from initial review:

1. The popover was anchored with position: absolute relative to the toolbar
   wrap, so when the queue panel sat near the viewport edge or the user had
   the window short, the popover overflowed and got clipped. Switched to
   position: fixed with a viewport-aware placement helper that prefers
   above the trigger, flips below when there isn't headroom, and shifts
   horizontally to stay inside the viewport. Recomputes on resize and on
   any ancestor scroll (capture-phase) so the popover stays anchored.

2. The Dropdown trigger was the visual peak of the stack — its default
   14px label dwarfed the surrounding eyebrows and made the popover read
   as inconsistent. Restructured the layout to a stacked eyebrow-above-
   control rhythm with one restrained type scale (11px eyebrow / 13px
   control / 12px action) and overrode the Dropdown trigger locally so
   it matches the input field. The countdown stays slightly larger (15px)
   as the deliberate focal point of the active state.

cc
2026-05-09 01:00:06 -06:00
vicrodh 8cde516887 feat(player): sleep timer with hard-stop pause (issue #402)
Adds a frontend-only sleep timer button to the queue panel toolbar. When
the deadline expires the timer invokes cmdPause() — no fade, no DSP, just
a hard stop consistent with QBZ's bit-perfect posture.

UX:
- Clock icon button next to the infinite-play toggle in QueuePanel.
- Click opens a popover with presets (30 min, 1 hr, 2 hr, 3 hr, 5 hr) plus
  a Custom option that reveals a 15-1440 minute number field.
- Once active, the button turns green and a small green countdown label
  appears beside it (no background, no border) showing the remaining time.
- Clicking the button again opens an Edit/Cancel view; Edit pre-fills the
  form with the current duration so the user can re-pick.

Behavior per spec:
- Frontend-only (single setTimeout fires the pause), no backend command.
- Timer does not persist across app restarts.
- Hard pause only; the app stays open so the user can resume where it
  stopped.

Translations added for en, es, de, fr, pt.

cc
2026-05-08 23:21:46 -06:00
vicrodh 569959da8b Merge branch 'feature/internal/local-library-tracks-perf' into pre-release
LocalLibrary Tracks tab perf trifecta — eliminates the minute-long freeze
on libraries with 16K+ Plex tracks.

* Tier 1a: batch Plex hydration with 5s per-call timeout
* Tier 1b + Tier 2: viewport-driven hydration with quality override map
* Tier 3: SQL ORDER BY + decorate-sort groupTracks
* a11y: silence misfiring linter warnings on tree sidebar resize handle

cc
2026-05-08 23:08:25 -06:00
vicrodh f447385fa0 chore(local-library): silence svelte a11y warnings on tree sidebar resize handle
The tree-sidebar resize handle uses role="separator" with aria-orientation,
aria-valuenow/min/max and arrow-key handlers — the WAI-ARIA splitter
pattern. Svelte's a11y linter classifies role="separator" as
non-interactive and flags both tabindex="0" and the mousedown/keydown
listeners, even though both are correct for a resizer.

Suppress the two rules locally with svelte-ignore directives so the
console stops printing them on every dev reload. Behavior unchanged.

cc
2026-05-08 23:07:28 -06:00
vicrodh 2f2dbe626b perf(local-library): push sort into SQL and drop localeCompare from groupTracks
Two cooperating changes that flatten the long sort tail in the Tracks tab:

1. search_with_filter now ORDER BYs (album, COALESCE(album_artist, artist),
   disc_number, track_number, title) COLLATE NOCASE — matching the default
   album-grouped browsing. SQLite handles 100K rows in well under 100ms;
   the JS sort took several seconds for the same volume.

2. groupTracks replaces the chained localeCompare with decorate-sort-
   undecorate using precomputed composite keys (lowercase strings with a
   sentinel separator and zero-padded numeric segments). The Intl collator
   instantiation per comparison was the bulk of the per-pass cost.

Both changes preserve the existing ordering semantics for the album, artist
and name modes; locale-specific accent ordering becomes lowercase ASCII
order, which is acceptable for library browsing.

Tier 3 of three.

cc
2026-05-08 23:04:36 -06:00
vicrodh bc4cd85a08 perf(local-library): viewport-driven Plex hydration with quality override map
Replace the bulk Plex hydration that fired on every Tracks-tab open with a
viewport-driven path. VirtualizedTrackList now emits onVisibleTracksChange,
LocalLibraryView debounces those events (150ms) and hydrates only the rows
in the visible window — at most a few dozen calls instead of 16K+.

Hydration results land in a reactive plexQualityOverrides map (SvelteMap).
getQualityBadge consults the map for plex tracks, so badges update without
reassigning `tracks`. That removes the second full pass through groupTracks
that the old `tracks = [...localTracks, ...hydratedPlexTracks]` reassignment
used to trigger after every hydration completion.

loadTracks is now a single reactive write; the requestId still invalidates
in-flight viewport batches when the search query changes.

Tier 1b + Tier 2 of three.

cc
2026-05-08 22:59:33 -06:00
vicrodh 1fe4f759a0 perf(local-library): batch Plex track quality hydration with per-call timeout
The unbounded Promise.all over every Plex candidate flooded the server's
per-host connection limit and held the JS event loop until the whole list
settled. With 16K Plex tracks the freeze ran into minutes.

Replace it with sequential batches of 5 (mirroring the existing pattern in
hydrateUnhydratedPlexTracks) and a 5s Promise.race timeout per call so a
slow/dead Plex host can't stall the whole pipeline.

Tier 1a of three; album-detail view uses the same helper so it benefits
even though its track scope is bounded.

cc
2026-05-08 22:58:12 -06:00
vicrodh 74e8bfa2f3 Merge feature/internal/local-library-folder-tree into pre-release
LocalLibrary Folders tab gains a Poweramp-style tree view (resolves #405):
two-column layout with a resizable tree rail, lazy-loaded children,
search-with-parent-preservation, recursive multi-select, compact album
detail and folder detail right panes, dedicated tree search, navigation
dropdowns in titlebar + sidebar, and many UX fixes layered in over
multiple iterations with the user.

cc
2026-05-08 21:27:31 -06:00
vicrodh c30f1408ca refactor(local-library): align album-art context menu with cover-context CSS
Replace the reinvented .artwork-context-menu-* classes with the
canonical .cover-context-* classes used by AlbumDetailView so the
menu visually matches the rest of the app. Menu still has only the
two simple options that apply to local library (View album art /
Edit metadata) - no custom-cover, browser, or save-as handlers, since
local-library artwork is a file path and cover changes belong to the
metadata editor. The ImageLightbox usage was already correct.

cc
2026-05-08 21:25:12 -06:00
vicrodh a942551e45 feat(local-library): album-art context menu with view + edit options
Right-clicking the artwork in the compact album view now opens a
small context menu with two options: View album art (opens the
existing ImageLightbox component) and Edit metadata (the existing
edit-modal flow). Click outside or Escape closes the menu.

cc
2026-05-08 21:15:13 -06:00
vicrodh bf747f836f feat(local-library): nav dropdowns for Local Library
Title bar and sidebar Local Library entries become dropdowns showing
Albums / Artists / Tracks / Folders in the user's configured order.
Clicking a tab navigates to the view and switches to that tab.

Two new shared stores back this:

  - libraryPreferencesStore: tab_order + hidden_tabs, hydrated by
    LocalLibraryView on mount and on save, plus a standalone fetch path
    used by the navigation components when the view hasn't mounted yet.
  - libraryTargetTabStore: a one-shot navigation target consumed by
    LocalLibraryView either at initial-mount time (inside the existing
    initialTabSet handshake in loadLibraryPreferences) or live via
    a dedicated $effect when the view is already mounted.

The title bar uses a hover dropdown that mirrors the existing My QBZ
pattern; the sidebar uses a collapsible parent row with NavigationItem
sub-items, matching the My QBZ section's visual register. Both filter
to the user's visible tabs only and respect the saved tab_order.

cc
2026-05-08 21:08:55 -06:00
vicrodh 02bd927254 style(local-library): drop the gradient HardDrive icon from the header
The page header now leads with the Local Library title alone. The
gradient drive square next to the title fought the rest of the chrome
without earning the visual weight, so it goes away. Markup and the
matching .header-icon CSS rule both removed; the .header flex container
is left intact for the title block.

cc
2026-05-08 20:58:30 -06:00
vicrodh 17686afe9d feat(local-library): right-click album art opens edit modal in tree mode
Right-clicking the artwork in the compact tree-mode album view now opens
the same album-edit modal as the pencil button in the page-level album
view. Plex source still requires the metadata-write opt-in, mirroring
the existing flow.

Implementation routes through a tree-specific helper that bridges the
modal's selectedAlbum / albumTracks dependencies to selectedAlbumForTree
/ treeAlbumTracks, gates the page-level album-detail takeover behind a
treeAlbumEditMode flag so the tree rail stays visible, and clears the
borrowed state through a cleanup effect once both album-edit and
tag-editor modals close.

cc
2026-05-08 20:57:36 -06:00
vicrodh de583baefa feat(local-library): selection + bulk actions in compact album view
Compact album view (tree-mode right pane) gains a selection toggle,
per-track checkboxes, and Play Next / Add to Queue / Add to Playlist
bulk actions alongside the existing album-level Add to Mixtape button.
When tracks are selected the mixtape button bulk-adds those tracks
instead of the whole album. Track search aligns to the right of the
action row (margin-left: auto). Selection resets when the user
navigates to a different album in the tree, so it doesn't bleed
across albums. Local + Plex split mirrors the tracks-tab handler so
each path stays in its own backend namespace.

cc
2026-05-08 20:38:39 -06:00
vicrodh b5f0acfac4 feat(local-library): folder-detail polish — circular play, search, list view
The big pill "Play all" goes away. Header gains a circular primary
Play button (action-btn-circle.primary) that replaces the folder
glyph and triggers recursive playback. A new right-aligned search
filters subfolder cards by name. View-mode toggle switches between
the existing grid and a new compact list view of subfolders.

cc
2026-05-08 20:37:17 -06:00
vicrodh 23865882b8 style(local-library): tighter tree toolbar (narrower search, 14px icons)
Tree search drops its flex-grow and pins to ~140px width. Check and
collapse-all icons shrink from 16px to 14px (2pt) for a tighter
register that matches the row icon size. Toolbar padding tightens
slightly to compensate.

cc
2026-05-08 20:32:33 -06:00
vicrodh fe7320b968 feat(local-library): tree toolbar revamp - flat check, collapse-all, dedicated search
The select-mode toggle drops its backgrounded style for a flat icon
that just changes color when active. New collapse-all button next to
it clears every expanded folder. Tree mode gains its own dedicated
search input alongside the toolbar buttons, decoupled from the
global album search. Folder rows lose the inline (N) track count for
a cleaner Plex/foobar register; the underlying count fetch stays in
place.

cc
2026-05-08 20:22:20 -06:00
vicrodh 9f1c3b37be feat(local-library): standard buttons + per-view track search in compact album
The compact album view's Play All / Shuffle buttons drop the pill
treatment and adopt the standard app button style. A new track search
input filters the displayed list by title or artist; empty results
show a short message. New i18n keys added to all five locales.

cc
2026-05-08 20:16:53 -06:00
vicrodh 6934c699b9 feat(local-library): page-level layout polish for Folders tab
Flat/Tree switch moves inline with the tabs row (right-aligned, low
weight, only on Folders). Page-level search icon hides on the Folders
tab — flat mode has its own inline search; tree mode gains a dedicated
tree-search in a follow-up. Two-column divider extends up to almost
touch the jumpnav by relocating the toolbar inside the tree column.

cc
2026-05-08 20:08:36 -06:00
vicrodh ed7246e82b fix(local-library): tree sidebar horizontal scroll + visible resize pill
The horizontal scrollbar never appeared on .folder-tree-scroll despite
rows declaring width: max-content. Root cause was contain: strict on
the scroll wrapper, which includes size containment; WebKit (Tauri)
under-reported scrollWidth so the bottom scrollbar was suppressed even
when long folder names extended past the rail. Drop size containment
(keep layout + paint for perf) and add flex-shrink: 0 to .row-name so
the flex row doesn't collapse the name and force the row's max-content
width to actually take effect.

The resize handle gains a centered vertical pill (4px x 36px, 40%
opacity by default, accent on hover/drag) so the divider has a visible
affordance independent of cursor rendering quirks under Wayland or
XWayland. Hit area widened to 8px for easier grabbing. Drag/keyboard
behavior and a11y attributes are unchanged.

cc
2026-05-08 19:52:37 -06:00
vicrodh 8d2cd41f88 feat(local-library): tighter tree sidebar default + horizontal scroll
Default width drops from 432px to 302px (~30% smaller). Long folder
names no longer ellipsize - the sidebar scrolls horizontally inside
itself when content exceeds the column width. Drag-resize and the
persisted width preference are unchanged: the user can still widen
to 40% of the content area, and that choice survives restart.

The resize handle stays anchored to the column's right edge because
it sits as a sibling of the scroll wrapper (.folder-tree-scroll),
not inside it - horizontal scroll inside the wrapper does not drag
the handle along.

cc
2026-05-08 19:37:58 -06:00
vicrodh 850cd41c13 style(local-library): minimalist tree rows
Tighter row padding (3px), smaller typography (~12.5px), 12px depth
indent, 14px icons. Removes the artwork thumbnail in favor of always
showing the folder icon. Track count moves inline as a muted suffix
(N) instead of a subtitle row, hidden when zero. Visual register
matches Plex/foobar2000 file-tree conventions.

cc
2026-05-08 19:31:17 -06:00
vicrodh 8ad30db8e3 feat(local-library): compact album view for tree right pane
New LocalLibraryFolderAlbumView renders a stripped album detail
(artwork + metadata + play + track list) sized for the tree-mode
right pane. The full-page album-detail view is unchanged; tree mode
no longer triggers the page-level takeover when an album folder is
selected, so the tree rail stays visible while browsing.

Tracks load into a separate `treeAlbumTracks` state via $effect on
`selectedAlbumForTree` (raceless: commits only when the selection
still matches at resolve-time). Play / shuffle / track-click set the
playback context against the tree-selected album so the now-playing
source label remains accurate.

cc
2026-05-08 17:59:51 -06:00
vicrodh fc9b59c1dd feat(local-library): drag-resizable tree sidebar (default x 1.8)
Sidebar default widens from 240px to 432px. A 6px right-edge handle
resizes between 200px and 40% of the content area. Width persists
across restarts via v2_set_library_folders_tree_sidebar_width on
drag-end. ResizeObserver tracks content-area changes (window resize +
app sidebar toggle) and re-clamps the user width when the cap shrinks.
Keyboard parity: arrow keys nudge in 16px steps (64px with Shift),
Home/End jump to bounds. Aria semantics use role="separator" with
valuenow/min/max. Five-locale i18n for the handle label.

cc
2026-05-08 17:49:37 -06:00
vicrodh 909365a34f feat(library): expose v2_set_library_folders_tree_sidebar_width
Tauri setter for the persisted sidebar width.

cc
2026-05-08 17:44:49 -06:00
vicrodh ce0b6ed28a feat(library-prefs): persist folders-tree sidebar width
New i64 field on LibraryPreferences for the tree-mode sidebar width
the user chooses via drag. Migration adds the column non-destructively;
NULL means "use default".

cc
2026-05-08 17:40:24 -06:00
vicrodh 053f7377c3 fix(local-library): default to first tab in user's order on mount
The view used to hardcode `activeTab = 'tracks'` on mount, which on
large libraries (16K+ tracks) causes a startup freeze regardless of
the user's preferred ordering. Now `activeTab` initializes to the
first visible tab in the user's configured order. Existing reactivity
that follows tab visibility changes (e.g. when the user hides their
current tab) is preserved.

cc
2026-05-08 17:33:15 -06:00
vicrodh 3c5522ec60 fix(local-library): real track counts on tree scan roots; nav-only tree
Top-level scan-root rows now fetch their recursive descendant count
via v2_library_count_folder_tracks instead of showing a hardcoded 0
(closing the Task 7 trade-off). Cache invalidates when the exclude-
network filter flips. The tree itself loses its per-row play button:
navigation-only, with recursive playback available from the right-
pane detail view.

cc
2026-05-08 17:30:34 -06:00
vicrodh fb825587a4 feat(library): expose v2_library_count_folder_tracks
Tauri wrapper for the qbz-library count helper.

cc
2026-05-08 17:28:02 -06:00
vicrodh 87fbc1bc4e feat(qbz-library): add count_folder_tracks_recursive
Lightweight COUNT(*) helper for the tree-mode scan-root rows that
needed a recursive descendant count. Same network-folder filter as
the listing primitives.

cc
2026-05-08 17:23:21 -06:00
vicrodh 69420dcb0d feat(local-library): pass excludeNetworkFolders to tree primitives
All tree-mode invokes route through shouldExcludeNetworkFolders() so
the tree rail mirrors the flat list and recursive-play boundary.
Closes the visible/play semantic mismatch flagged by Task 10.

cc
2026-05-08 17:05:06 -06:00
vicrodh 9b5a9ffaf5 feat(library): plumb excludeNetworkFolders through tree commands
v2_library_list_folder_children, v2_library_list_folder_tracks,
v2_library_list_folder_tracks_recursive accept and forward the
exclude-network-folders flag.

cc
2026-05-08 17:02:55 -06:00
vicrodh 1d68f7516e feat(qbz-library): network-folder filter on folder-tree primitives
list_folder_children, list_folder_tracks, list_folder_tracks_recursive
now apply the same network-folder exclusion that flat-mode search
honors, so the tree rail and recursive playback see the same source
of truth. Tests cover the on/off cases.

cc
2026-05-08 16:58:59 -06:00
vicrodh 632accbe40 fix(local-library): clear tree-mode state in bulk-action handlers
handleBulkPlayNext, handleBulkPlayLater, handleBulkAddToPlaylist now
call resetMultiSelect() so treeSelectMode and treeSelectedTracksById
are cleared alongside the existing flat-mode reset. Without this, the
BulkActionBar lingered with count 0 after firing from tree mode. The
tree-mode BulkActionBar's onClearSelection closure now delegates to
the same helper for consistency. resetMultiSelect was already
correct (it leaves trackPathById intact, which is what we want).

Also: warn when toggleTreeTrackSelection's cache-miss fallback hits a
track path with no parent folder, instead of silently returning.

cc
2026-05-08 16:37:15 -06:00
vicrodh 5e2670f59f test(qbz-library): empty-folder case for list_folder_tracks_recursive
Verify the recursive listing returns an empty Vec (not an error) when
the requested folder path has no descendants — frontend treats empty
as "nothing to play/queue" and skips the toast.

cc
2026-05-08 16:37:08 -06:00
vicrodh 3a09d8deb8 feat(local-library): recursive multi-select in tree mode
Folder-row checkbox in tree mode selects every descendant track via
v2_library_list_folder_tracks_recursive, deselects them on a second
click, and shows the standard indeterminate state when only some
descendants are selected. Track-kind rows in the tree honour the
same flow so users can deselect individual tracks from a partially-
selected folder. Both checkboxes share the existing selectedTrackIds
set, so the bulk-action bar (play next, play later, add to playlist)
operates on the union without any special-case handling.

Indeterminate state is computed cheaply: a SvelteMap<number, string>
caches track-id -> file_path (populated from the in-memory tracks
list and from each recursive fetch on toggle), and `countSelectedUnder`
walks the small selectedTrackIds set checking the prefix. Folder-row
state derives via a parent-supplied callback; the child reads the
prop reference of selectedTrackIds so reactivity propagates when the
parent reassigns the set.

Adds a dedicated treeSelectMode flag so the flat-mode tracks-tab
selection toggle stays independent. Renders the BulkActionBar at the
end of the tree-mode container when treeSelectMode is on. Adds a new
toast.failedSelectFolderTracks key across all five locales.

cc
2026-05-08 16:27:34 -06:00