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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
When the user types in the search input while in tree mode, matching
folders/tracks stay visible and every ancestor path expands to keep
context. Empty branches collapse out. Clearing the query restores the
user's pre-search expand state. Implementation: in-memory contains over
the loaded `albums` array, debounced 200ms. The match expands the union
of matched paths and their ancestors in `searchExpandedPaths`, which
replaces `treeExpandedPaths` while search is active. Pre-search snapshot
is restored on clear. Tracks remain rendered under any visible folder.
cc
- Surface a toast on playback failure so the user gets UI feedback
rather than only a console error.
- Honor shouldExcludeNetworkFolders() for consistency with loadTracks
and the rest of the view's search invocations.
cc
Wires LocalLibraryFolderTree (left rail, 240px fixed) and the
LocalLibraryFolderDetail / album-detail flow (right pane) into a
two-column shell that mirrors the Artists tab CSS byte-for-byte.
View-mode segmented control on the Folders toolbar toggles between
Flat (the existing folder-grouped list) and Tree (the new hierarchical
view). The toggle is hidden when browsing Hidden Albums so that
sub-view keeps its existing flat rendering.
Selection routes the right pane: folder paths matching a known
album_group_key drive the existing handleAlbumClick flow (which sets
selectedAlbum and renders the top-level album-detail view); non-album
folders open LocalLibraryFolderDetail with subfolder cards plus
direct-children tracks. Empty-state when nothing is selected uses the
library.foldersTree.selectAFolder string.
Recursive play: handlePlayRecursive fetches all tracks via
v2_library_search (limit:0), client-side prefix-filters on file_path,
sorts by path and queues the result. Backend does not yet expose a
recursive-tracks-under-path command — flagged as a Task 7 trade-off
to revisit if telemetry shows tree-mode play stalling on large
libraries.
Mode persists in library_preferences.folders_view_mode via the
existing v2_set_library_folders_view_mode command, hydrated on mount
through v2_get_library_preferences. Flat mode rendering is
byte-for-byte unchanged: zero deletions in the existing markup, the
new tree shell wraps it via an outer if/else.
LibraryPreferences type extended with the optional folders_view_mode
field so TS strict-mode passes on the new prefs.folders_view_mode
read.
cc
The folderPath snapshot existed but wasn't used to guard the async
writes. Rapid folder switches could land an older fetch's results on
top of the newer selection. Add three short stale-guards on .then,
.catch, and .finally so only the latest in-flight result wins.
cc
Renders the contents of a folder that is NOT a known album_group_key —
shows immediate subfolders + direct-children tracks side by side, with
a "Play all recursively" button for the whole folder. Used in the right
column of the upcoming tree-mode two-column layout (Task 7). Extracts
FolderTreeEntry to a shared $lib/types/folderTree.ts module so both
LocalLibraryFolderTree and FolderDetail import the same type.
cc
- Keep self-import for recursion and replace the misleading "deprecated
in Svelte 5" comment with a corrected rationale. svelte-check confirms
`<svelte:self>` is in fact deprecated in Svelte 5 (svelte_self_deprecated)
and self-import is the recommended replacement; the original code was
right, only the comment was wrong.
- Switch the row role from `button` to `treeitem` so that `aria-selected`
is valid (svelte-check rejects aria-selected on role=button) and add
`aria-expanded` for folders. treeitem is also the semantically correct
role for a tree row, so screen readers now announce both selection and
expansion state.
- Add :focus-within parity for the play button so keyboard users see it
when the row receives focus.
cc
Lazy-loaded recursive tree-row component for the upcoming tree mode in
the Folders tab. Renders one node per call; children loaded on first
expand via v2_library_list_folder_children. ADR-001 compliant (no $t
inside $derived). Indentation by depth * 16px matches Poweramp-style
hierarchy. Recursion uses self-import pattern (svelte:self deprecated
in Svelte 5). Not yet wired into LocalLibraryView - that lands with
the two-column layout integration (Task 7).
cc
Task 4's structural change of `library.folders` from string to object
broke LocalLibraryView.svelte:4337's dynamic tab label
($t(`library.${tab}`)) which expects a string. Move the new tree keys
to a sibling namespace `library.foldersTree.*` instead, and restore
`library.folders` to its original flat string value.
cc
Five locales (en, es, de, fr, pt). en and es are human-written in the
project's canonical Mexican neutral register; de/fr/pt are machine-
translated drafts flagged in qbz-nix-docs/i18n/pending-translations.md
for community review. Seven keys: modeLabel, flatLabel, selectAFolder,
treeFolderTracks (ICU plural), treeLoading, treeEmpty, playAllRecursive.
The flat library.folders string ("Folders" / "Carpetas" / etc.) was
migrated to an object; the original label is preserved at
library.folders.label. No source code currently references the key,
so the restructuring is non-breaking. "Track" is translated to match
each locale's existing convention (pista, Titel, titre, faixa).
cc
Three issues observed when fast-scrolling collection detail views
(reported on the Macross collection, ~67 items, list mode):
1. Horizontal scroll artifact at the bottom-right corner — same root
cause as AlbumDetailView's: an absolutely-positioned artwork backdrop
widens past 100% of the .detail-view. Add overflow-x: hidden alongside
the existing overflow-y: auto.
2. Empty container when scroll position briefly exceeds the content
tail. The virtualWindow math could compute firstRow * cols >= total
during overscroll moments, slicing windowedItems to an empty array.
Clamp firstRow / firstIdx to total - 1 so at least one row renders
even when the scroll position has overshot.
3. The user-visible regression that started this thread: scrolling
UP from deep in the list could not reach scrollTop = 0 even though
the "Top" button worked. Two fixes layered together:
a. overflow-anchor: none on .detail-view. Browser scroll-anchoring
tries to preserve visual stability when content above the viewport
changes height — but the virtualizer manages its own spacer
heights, so the auto-anchoring nudges scrollTop in the opposite
direction of user input. Disabling it lets the wheel input win.
b. content-visibility: auto gated to expanded mode. Until now
.item-block applied it unconditionally. The grid view's .grid-card
already had this exact issue diagnosed and fixed (see the inline
comment "doubling up with content-visibility: auto caused cards to
re-eject and re-paint on scroll-back"); .item-block had been left
behind. Layering native content-visibility on top of manual
virtualization caused the browser to intermittently treat rendered
rows as off-screen during fast scroll, snapping them to the
contain-intrinsic-size hint for a frame, which the spacer math
doesn't predict — producing the empty-viewport-with-stuck-scroll
symptom. The .lazy class now scopes content-visibility to expanded
mode, where virtualization is OFF and the optimization is needed.
Smoothness during very fast wheel scroll is still imperfect but the
viewport no longer goes blank and scroll-up reaches the top reliably.
cc
The .has-art-bg ::before pseudo-element renders the album artwork
heavily blurred at transform: scale(1.15) for the Qobuz/Feishin-style
header. Without overflow-x: hidden on the .album-detail container, the
15% horizontal upscale of the absolutely-positioned backdrop bleeds
past the right edge and surfaces a horizontal scrollbar on the page.
Add overflow-x: hidden to the existing overflow-y: auto so the blur
stays clipped without affecting vertical scroll.
cc
Adds CoreAudio Hog Mode integration on macOS so Exclusive Mode actually
takes the device exclusively (previously the macOS path used the
Linux-shaped logic without any kernel-level lock). Pins the CoreAudio
device id through Hog Mode acquisition, sample-rate switching, and
stream creation so System Default doesn't drift mid-session. Linux
behaviour is unchanged.
Review asks (all four resolved by Vudgekek + afonsojramos):
- set_coreaudio_hardware_volume falls back to software volume on Err
rather than reporting success, so knob-only DACs keep a working
volume slider via engine.set_volume().
- CoreAudioExclusiveGuard snapshots the device's hardware volume on
acquire and restores it before releasing Hog Mode, so leaving
Exclusive Mode returns the DAC to the user's pre-Exclusive level.
Guard is constructed before the FFI call so partial-acquire failures
still trigger Drop and release.
- Linux unused-variable warnings on stream_opt and exclusive_guard
cleaned up via cfg_attr(allow(unused_variables)) on the macOS-only
guard plumbing. cargo check is clean.
- Silent shared-mode regression on macOS when backend init fails:
init_device now sets_current_device(None) and returns None on
exclusive_mode==true so the user sees an error instead of
unknowingly playing in shared mode.
Bonus: 5f10b4ba adds real CoreAudio device-rate detection for macOS
Shared Mode, +90 lines in backend.rs.
Conflict resolution: single textual collision in
crates/qbz-audio/src/lib.rs (both branches added a pub use line).
Resolved by keeping both, alphabetical order — coreaudio_direct
re-export from pr-391, device_reservation re-export from pre-release.
All other auto-merges verified line-by-line: AlsaDirectStream's
DeviceReservation::acquire flow (alsa_direct.rs:60-97), the macOS
exclusive_mode guard (player/mod.rs:1077-1085 in qbz-player and the
mirror in src-tauri:1024), and CoreAudioExclusiveGuard's volume
snapshot/restore (coreaudio_direct.rs:351,369,373,388) all coexist
correctly because each branch touched different functions.
cargo check clean across all four crates. 9 device_reservation +
3 reserve_dac + 214 src-tauri unit tests pass.
cc
Mirrors the HiFi Wizard control (landing in Task 8) inline under
Exclusive Mode. Status row polls v2_get_dac_reservation_status every
5s while the row is rendered. Hidden when the selected device is not
a card-specific ALSA prefix (hw:, plughw:, front:, surround*, iec958:,
hdmi:). Six i18n keys consumed at settings.audio.reserveDac.*.
cc
Five locales (en, es, de, fr, pt). English and Spanish are human-written
in the project's canonical Mexican neutral register; de/fr/pt are
machine-translated drafts. Pending-translations note recorded in
qbz-nix-docs/i18n/pending-translations.md (outside git scope).
Keys: settings.audio.reserveDac.{label, description, statusActive, statusContested, statusUnavailable, wirePlumberNote}.
UI surfaces (Settings mirror + HiFi Wizard) consume these in Tasks 7-8.
cc
Adds a Discord Rich Presence section to Settings -> Integrations:
- Toggle wired to discordRpcService (off by default, persisted in localStorage)
- Collapsible override block under the toggle with the flatpak override
command and a Snap fallback note. Always shown - runtime detection isn't
worth the complexity for a help block.
Refs #336.
cc
Adds settings.integrations.discordRpc keys (label, description, overrideHeader,
flatpakNote, snapNote) to en/es/de/fr/pt for the Discord Rich Presence settings
toggle and sandbox override block.
Refs #336.
cc
Calls discordRpcService.initDiscordRpc() once from +page.svelte onMount so
the backend state matches the persisted toggle and the playerStore subscriber
is registered when the user has opted in.
Refs #336.
cc
Creates discordRpcService that:
- persists the user toggle in localStorage (qbz-discord-rpc-enabled, off by default)
- on enable, subscribes to playerStore and forwards (track_id, is_playing) transitions
- dedupes by (track_id, is_playing) so currentTime ticks don't spam the backend
- silently ignores backend errors (headless, sandbox-blocked, etc.)
Refs #336.
cc
Promote the album/artist detail header gradient from a flat
palette-color fade to a Qobuz/Feishin-style blurred backdrop, and
extend it to the artist page.
- artworkPalette: route the URL through getCachedImageUrl before
handing it to Vibrant. Qobuz CDN sends Access-Control-Allow-Origin
for album covers but not artist images, which tainted the canvas
Vibrant uses and made pixel reads fail. The cache resolves to a
local asset:// path and primes the disk store for next time.
- AlbumDetailView and ArtistDetailView: ::before now renders the
artwork itself with filter:blur + saturate, masked to fade into the
theme background; ::after carries the contrast scrim so the blur
layer doesn't muddle it. The CSS variable --art-image-url carries
the locally-resolved URL so the backdrop image is served from disk
instead of being re-downloaded.
- Heights tuned per view: artist's header is taller (image + bio +
actions) than album's, so artist gets 340 × scale(1.1) and album
gets 280 × scale(1.15) so each fade lands above its respective
toolbar / JUMP TO bar.
- ArtistDetailView jump-nav: now transparent at rest and gains its
background only when stuck (IntersectionObserver against a 1px
sentinel placed right above it). Stops the bar from cutting a
flat-color stripe across the gradient when it's not pinned.
- Reactive text + action button overrides on .has-art-bg apply to
the artist page too: back button, bio text, bio toggle, and the
action-btn-circle family read in white-tinted variants over the
colored backdrop.
cc
Custom sort had two parallel selection systems: an inherent per-row
checkbox column (selectedTrackKeys) used solely to enable Move Up/Down
in a separate batch toolbar, and the toolbar's multiSelectMode for
bulk actions. They had different stores, different widths, and one
disabled the other. Consolidate onto multiSelectMode; drag and the
existing per-row reorder controls cover ordering, and the wrapper
propagates dragTrackIds for group moves.
- Drop selectedTrackKeys, isSelectionMode, toggleTrackSelection,
clearSelection, selectAllTracks, moveSelectedUp, moveSelectedDown
and the associated batch-controls toolbar / col-checkbox header
branch / inherent per-row checkbox.
- Move the multi-select checkbox out of TrackRow into the wrapper so
the column order in custom sort reads [checkbox][reorder][TrackRow];
TrackRow's internal track-checkbox is hidden via :global so its
click + keyboard handlers stay wired through selectable=true.
- Reserve 24px for the row-checkbox and the header col-select-all at
all times and animate opacity (180ms) instead of width. Toggling
select no longer reflows the columns to its right; the checkbox
fades inside a slot that was always there.
- Stable each-key for virtualized rows: use track identity
(`<namespace>-<id>-<number>`) instead of `${visibleTrackRange.start
+ loopIdx}-${track.id}`. The old key mixed scroll state into the
identity, so a re-render that bumped trackListScrollTop reused row
instances against new data — rows showed mismatched track / # /
album combinations until the next paint.
cc