Mixtapes & Collections release. Bumps all version references
(Cargo.toml, tauri.conf.json, package.json, snapcraft.yaml, both
Cargo.lock, package-lock.json), adds the 1.2.8 entry to the Flatpak
metainfo, and sets the About modal codename to "My QBZ".
cc
Revises the previous commit's plex-in-queue filter. Turns out the
backend queue is a passive data structure — it doesn't play tracks
itself. Playback on queue advance goes through the frontend's
onTrackEnded callback:
nextTrack() → BackendQueueTrack → playQueueTrack → playTrack({..., source})
…and playTrack already dispatches source='plex' to v2_plex_play_track
on line 299. BackendQueueTrack has an optional `source?: string` field
for exactly this reason; the previous build just wasn't populating it
for playlist tracks.
Changes in buildQueueTracks:
* Plex tracks are no longer filtered out of the queue.
* Each queue entry now carries an explicit source: 'plex' | 'local'
| 'qobuz' marker derived from the DisplayTrack's flags. That's
what resolvePlaybackSource reads to pick the right playback
command when auto-advance fires.
* localTrackIds (used by offline-mode availability) still only
contains filesystem-local ids — plex ids shouldn't land in there
because that Set is validated against local_tracks.
Direct-click plex playback still works via handleTrackClick's isPlex
branch (commit f9b7290e). The remaining limitation — Play Next / Play
Later for plex tracks — is still a no-op because those enqueue-single
paths haven't been taught to pass source='plex' through to the
backend's add-to-queue; separate follow-up.
cc
Plex tracks added to a Qobuz playlist weren't playable:
* Clicking the row hit onTrackPlay → playTrack with no source arg →
backend treated the id as a Qobuz track id and rejected it with
"invalid track id for remote handoff: -1000017964" (the negative
plexDisplayId UI-only offset was the id we sent).
* Auto-advancing from the previous (local) track mapped the plex
DisplayTrack's negative id through Math.abs into the local-track
queue, which then looked 1_000_017_964 up in local_tracks and
errored with "Track not found".
Changes:
1. plexTrackToDisplay now uses Number(ratingKey) as id when the
ratingKey parses as a positive integer, matching the shape Plex
backend / LocalLibraryView already use (PlexCachedTrack.id =
playback_track_id(rating_key) = the numeric rating_key for the
common case). The negative-offset hash stays as the fallback for
non-numeric rating keys — those still need UI-unique ids but are
rare enough that no-play is an acceptable degradation.
2. isLocal stays true so the Qobuz context-builder and blacklist
filter keep their existing semantics; the new isPlex flag drives
routing at the dispatch sites.
3. PlaylistDetailView.Props gains onPlexTrackPlay (track) → void.
handleTrackClick checks track.isPlex first and dispatches through
it; handleTrackPlayNext / handleTrackPlayLater no-op on plex for
now (backend queue doesn't understand plex yet).
4. removeTrackFromPlaylist checks track.isPlex first and calls
v2_playlist_remove_plex_track with ratingKey: String(track.id) so
the "Remove from playlist" menu item works on plex rows too.
5. +page.svelte gets handleDisplayPlexTrackPlay — mirrors
handleLocalTrackPlay's plex branch (playTrack with source='plex',
isLocal:false). Wired via onPlexTrackPlay on the PlaylistDetailView
render.
6. buildQueueTracks filters plex out entirely. The local-track queue
path in the backend looks ids up in local_tracks; plex ratingKeys
aren't there and the mismatch was the source of the auto-advance
"Track not found" toast. Plex tracks keep their direct-click
playback; gapless auto-advance across plex will require backend
queue awareness (deferred).
Known limitation carried forward: Play Next / Play Later / auto-
advance across plex tracks are no-ops until the backend queue learns
to cross-reference plex_cache_tracks. Direct click on a plex row
plays correctly.
cc
New installs open with the core nav items already in the custom
titlebar so the app feels "populated" right after login. Purchases is
excluded from the default set because it's an opt-in Qobuz feature
that not every account uses — keeping it off avoids a dead button for
most users.
Existing users are untouched. Both stores read localStorage in their
init* function and overlay the persisted values on top of these
defaults, so anyone who deliberately moved nav items back to the
sidebar (or never cared and just got the old all-false defaults)
keeps whatever they have.
Defaults now:
titlebarNavStore: discover / favorites ("Library") / library
("Local Library") / myQbz → true. purchases → false.
searchBarLocationStore: titlebar.
No effect on users who toggled off the custom titlebar itself — the
config still exists but isTitlebarNavEnabled() is a no-op for them.
cc
Offline-cached Qobuz tracks should appear in the Local Library on a
fresh install — the whole point of caching is to have them available
alongside local files. Users who set the preference explicitly (on or
off) are untouched: the INSERT OR IGNORE in DownloadSettingsStore
only writes the default row when no row exists yet.
Three coordinated changes inside download_settings.rs:
* DownloadSettings::default().show_in_library: false → true
* Schema DEFAULT on the column: 0 → 1 (covers rows added post-
migration from code paths that skip the explicit INSERT)
* The INSERT OR IGNORE seed row now interpolates
default_settings.show_in_library instead of hardcoding 0, so the
code default and the SQL default stay in sync.
cc
The immersive panels defaulted to 15fps (linebed was the sole
override at 60). 15 is visibly choppy for audio-reactive motion; 60
is overkill for everything except the linebed terrain. 30fps is the
middle ground — perceptually smooth for the slow-evolving visuals
while keeping CPU/GPU headroom for machines that didn't ask for 60.
* DEFAULT_FPS 15 → 30
* linebed's 60 override removed (30 also reads fine for it)
getPanelFps still prefers the stored qbz-immersive-fps-<panelId> key
in userStorage when set, so users who manually tuned a panel higher
or lower keep their value. The new default only kicks in for panels
with no stored preference — i.e. new installs and panels the user
never touched.
cc
The Collection / Mixtape detail hero was the outlier across app
detail views — 240px cover and 40px title against the 186px cover
and 24px title every other detail view (playlist, album) uses. That
made those two feel visually louder and taller than everything else.
Aligns with .playlist-header sizing:
* cover 240 → 186
* title 40px → 24px / line-height 1.2
* kind tag 10px accent → 12px text-muted (matches .playlist-label)
* gap 24 → 32
* description line-height 1.4 to match
header-info stays column+flex-end so the metadata hugs the bottom
edge of the cover (same alignment as playlist / album detail views).
cc
Pure-CSS spinner on .grid-artwork-wrap::after, visible through the
transparent <img> element while it's mid-fetch. Once the image's
bytes paint, the opaque pixels cover the pseudo-element — no JS load
handler, no per-card reactive state, no extra re-render on load.
Placeholder cards (no artwork URL at all) hide the spinner behind
their own solid background via the lifted .grid-artwork-placeholder
rule.
Addresses the user's "add a spinner while caching" ask for the grid
view; the dark-square-during-fetch look was the bit that still felt
heavy even after preload + sync cache hits landed.
cc
Plex thumbnail URLs carry X-Plex-Token (and sometimes
X-Plex-Client-Identifier) as a query parameter. Those rotate each
time a token refresh lands, so the *exact same* /library/metadata/…/
thumb resource hashes to a different cache key between sessions, and
the backend dutifully re-downloads and re-caches the same bytes under
the new hash. On a collection that's mostly Plex (the user's 67-item
case), that means every open re-pulls 67 images over the network —
which is indistinguishable from "caching isn't working".
Normalize the cache key on both sides, keeping the original URL for
the actual authenticated fetch:
* src-tauri: cache_key_for() strips X-Plex-Token and
X-Plex-Client-Identifier before hashing. The `url` passed to
reqwest is still the original with token, so the GET authenticates
correctly; only the on-disk cache key is stable across sessions.
* frontend: same normalization in imageCacheService.cacheKeyFor()
covers the in-memory resolvedUrls map, pendingRequests dedup,
getResolvedIfCached sync hit, and preloadImages "skip if already
cached" short-circuit.
Non-Plex URLs are untouched — no '?' means no normalization work.
Qobuz's static.qobuz.com CDN URLs don't carry session params, so they
already cache correctly and this is a no-op for them.
cc
Grid-mode is already virtualized (windowedItems slice + top/bottom
spacers), so layering content-visibility:auto on top of that meant
every card off the viewport got ejected and re-painted when it came
back — the "areas ya scrolleadas se van a negro" the user flagged in
the recorded session.
Virtualization alone limits rendered cards to viewport + buffer, which
is what we actually want. content-visibility stays on .item-block
(list / expanded modes) where virtualization is disabled — that's the
mode that genuinely needs the engine-level skipping.
cc
The scroll lag in the grid view wasn't rendering cost — video analysis
(30fps extraction of a real session) showed consecutive frames stuck
identical (frame drops during scroll) plus dark placeholder cards
appearing at the bottom edge. Both symptoms trace back to the
cachedSrc action: it set opacity:0 + no src, then awaited a backend
proxy invoke per card before showing anything. On a 67-item collection
scrolling 30+ new cards into view, each new card paid a round-trip to
v2_get_cached_image before rendering — that's the placeholder flash,
and the JS/IPC load is what was eating the frame budget.
Three coordinated changes:
1. MixtapeCollectionDetailView.loadCollection() primes the image map
up front via preloadImages(). By the time the user scrolls, every
item's artwork URL has a resolved asset:// entry sitting in the
resolvedUrls map. Requests the _150 variant so the preload matches
what the grid cards actually display.
2. imageCacheService gains getResolvedIfCached() — a synchronous peek
into the in-memory map. Returns the resolved URL if hot, undefined
otherwise. file:// and asset:// short-circuit.
3. cachedSrc restructures around that sync path:
- If the cache is warm, <img src> is set synchronously on mount +
opacity stays at 1. Zero placeholder frames.
- If not warm, <img src> falls back to the raw URL immediately so
the WebView starts fetching in parallel with the backend proxy
call. If the proxy returns a disk-cached asset:// we swap it in
silently. No more opacity-dance between "loading" and "loaded".
Net effect: the first frame of each newly-visible card is already
painted with real artwork when the map was preloaded; the fallback
path only costs the raw HTTPS fetch (which the WebView already
handled in the old code too, just gated behind the proxy call).
cc
Browser-native offscreen skipping for the detail view's heavy paths.
Grid:
* .grid-card gains content-visibility: auto + contain-intrinsic-size
of 170×240. The engine can now skip layout, paint, and compositing
for cards scrolled out of the viewport — even though virtualization
already clips the windowedItems slice, the buffer rows and items
briefly off-screen during scroll were still fully rendered, which
is the cost that was showing up as stutter on large collections.
* Artwork request size bumps from _150 to _230 so the Qobuz thumbnail
matches the actual card width on the common 170–240px range. No
size change on HiDPI scaling, so crisper covers at the same-ish
wire cost (cachedSrc dedupes identical URLs across cards).
Expanded / list:
* Wrap each item-row + its optional expanded-tracks block in a new
.item-block. Apply content-visibility: auto there too with an
intrinsic hint of 100%×56 (collapsed row height). In expanded mode
the recent fix to render every visibleItem made this necessary —
50 items with all their TrackRows in the DOM was the pain point;
now the engine skips rendering for anything scrolled away,
bringing the cost back down to viewport-bounded even without
virtualizing the TrackRows themselves.
cc
Grid virtualization was using a hard-coded GRID_ROW_HEIGHT = 240.
That worked when cards were ~170px wide (the minmax floor), but
.item-grid uses auto-fill with 1fr tracks — on wider containers
each card expands past 170 and its 1:1-aspect artwork grows with it.
A card at 210px wide is ~272px tall, not 240. Over a 10-row grid
that's 320px of unaccounted content, same failure mode as the
expanded-row case: scrollbar stops short of the real bottom.
Replaces the constant with gridRowHeight derived from the actual
card width ((listContainerWidth - gaps) / gridColumns) plus a chrome
constant (title + subtitle + padding + inter-card gap). The
virtualWindow math now reads gridRowHeight each time instead of the
old fixed number, and it updates automatically when the user resizes
the window because listContainerWidth is already observed by the
existing bind:clientWidth.
Knock-on: the constant GRID_ROW_HEIGHT is gone; GRID_CARD_CHROME_H
takes its place as the part that doesn't depend on width.
cc
The detail view virtualizes items with a fixed LIST_ROW_HEIGHT of 56.
In list / grid modes the real row height is 56 by design, so the math
works. In expanded mode the row grows every time a TrackRow mounts
below the item header — a typical album-expanded row is 300–500px,
not 56. The reserved top/bottom spacers fall far short of real
content height, so the scrollbar thinks the page ends where the
spacer math says it should and the user can't reach the last row:
"scroll se regresa, casi imposible llegar al final".
Fix: in expanded mode render every visibleItem (no slicing, no
spacers). The item-row shell is lightweight (56px + a tiny meta
block); only the TrackRows below are expensive, and those were
already gated by the same virtualWindow indices we keep using for
ensureTracksLoaded — so mounts stay bounded to the viewport as
before, only the skeleton rows are now fully rendered.
list and grid modes are unchanged — they still slice windowedItems
and use the virtual spacers as before.
cc
Frontend half of the network-mount detection. PlaylistLocalTrack
(from v2_playlist_get_local_tracks_with_position) and DisplayTrack
both gain an isNetworkMount field populated from the new
local_tracks.is_network_mount column.
isTrackAvailable now checks the flag first and only falls back to
the string heuristic when the flag is missing — either because the
row predates the migration and hasn't been re-scanned yet, or because
the track was loaded on a platform where backend detection isn't
wired (macOS, Windows).
Result: the sandbox case the user flagged — GVFS-mounted NAS share
bind-mounted at /home/user/music — now properly marks tracks as
offline-unavailable when the wire is cut, where the previous
string-only heuristic would have treated them as local.
cc
Adds an is_network_mount flag to local_tracks. Populated by reading
/proc/mounts (falling back to /run/host/proc/mounts for Flatpak
sandboxes) and matching the file path's longest-prefix mount against
a list of network filesystems — nfs/nfs4, cifs/smb/smb3, fuse.sshfs,
fuse.rclone, fuse.gvfs, davfs/webdav, 9p, ceph, glusterfs, afs, afp.
Motivation: the earlier offline-awareness commit used a frontend
heuristic that matched /mnt, /media, /run/media and Windows UNC. That
catches the classic cases but misses the common sandbox case — a user
on Flathub or Snap mounts their NAS via GVFS at /run/user/1000/gvfs/…
or bind-mounts it into /home/user/music, and neither of those
triggers the frontend check. The backend sees the mount type
directly, so flagging the row at index time gives the UI ground truth.
Changes:
* new crates/qbz-library/src/mount_info.rs — is_network_path(&Path)
helper with /proc/mounts parsing, longest-prefix mount matching,
and a NETWORK_FS_PREFIXES list. Guarded with cfg(target_os =
"linux"); stubs return false on macOS / Windows (frontend keeps
its string-match heuristic there).
* LocalTrack gains is_network_mount: bool (serialized camelCase).
* local_tracks table: new is_network_mount INTEGER NOT NULL DEFAULT
0 column, both in the initial CREATE TABLE and a migration for
existing DBs.
* insert_track calls mount_info::is_network_path(&track.file_path)
and writes the flag. Qobuz cached-download inserts rely on the
column default (always 0) since those land inside the app's own
data dir.
* TRACK_COLUMNS + row_to_track pick up the new column. The two
inline LocalTrack builders in get_playlist_local_tracks and
get_playlist_local_tracks_with_position are updated the same way,
plus the playlist_position index shifts by one.
cc
Builds on the offline-awareness work: differentiates forced offline
(no_network / not_logged_in — the wire is actually cut) from manual
offline (user toggle — network is up, they just chose not to use it).
Under forced offline, two extra groups can't be reached even though
they show up as "local" in the UI:
* Plex tracks. The Plex server lives on the network; no network
means the track is as gone as a Qobuz stream.
* Network-mounted local files. If the path looks like a network
mount (Linux /mnt, /media, /run/media, /net/ autofs, or Windows
UNC \\host), the filesystem IS the network and the same rule
applies.
Under manual offline the network is still physically available, so
both groups stay playable. Qobuz is the only source blocked in that
mode — that's the user's explicit choice.
Changes:
* DisplayTrack gains isPlex (source is Plex server) and filePath
(raw audio path) fields, both in the global types and the
playlist detail's local interface.
* plexTrackToDisplay marks isPlex: true.
* localTrackToDisplay forwards the file_path.
* isTrackAvailable reads offlineStatus.reason to branch:
- Plex → unreachable under forced offline, reachable otherwise.
- Local with isNetworkPath(filePath) → same rule.
- Plain local filesystem tracks → always reachable offline.
- Qobuz → needs an offline copy as before.
* New isNetworkPath helper — heuristic match on common mount paths
plus Windows UNC. Best-effort since /proc/mounts isn't available
from the webview.
Effect: when the user yanks the cable with a playlist open, Plex
tracks and network-mounted rows join the Qobuz-without-cache ones in
getting greyed out, skipped by Play All, and auto-advanced past during
playback — same treatment, now consistent across sources.
cc
handlePlayAll and its two sibling handlers (handlePlayAllNext,
handlePlayAllLater) already filtered blacklisted tracks out of the
queue at enqueue time. They now also drop tracks that
isTrackAvailable() rejects — so a playlist queued in offline mode
contains only the tracks we can actually play, instead of padding the
queue with rows that will fail and auto-skip one after another.
The auto-skip from the earlier playbackService commit is still the
runtime safety net (e.g. a track goes missing mid-queue, or the user
toggles offline mode), but the common path — "user clicks Play on a
big playlist while offline" — now builds a clean queue up front:
* No burst of "not available offline" toasts.
* The "playing N of M" counter matches what's really scheduled.
* Playback context only includes reachable Qobuz ids, so server-
side next/prev aligns with what the client sees.
Filter logic is centralized in the existing playableTracks predicate
so all three Play-All entrypoints share the exact same rule.
cc
Offline-unavailable tracks (no local copy, we're in offline mode) now
render with the same grayscale + opacity treatment the view already
applies — isUnavailable is passed into TrackRow so the play glyph is
replaced with the unavailable icon, and the existing
.track-row-wrapper.unavailable class (which was already being
assigned but short-circuited with `pointer-events: none`) dims the
row and desaturates it.
Changes:
* Switched .track-row-wrapper.unavailable from pointer-events:none
to pointer-events:auto (matches the sister .removed-from-qobuz
behavior) so right-click still opens a context menu. Opacity
bumped from 0.4 to 0.5 for consistency with removed rows.
* Populated menuActions for the offline-unavailable case — go to
album, go to artist, show info, and remove from playlist when
it's the user's own playlist. Intentionally omits play*, add to
playlist, download, and (per user request) Find Replacement, since
offline is a transient state and none of those actions would help.
* TrackRow kept simple: the red CircleAlert icon it already rendered
gets desaturated by the parent's grayscale filter, so the offline
state reads as muted without needing a dedicated icon variant.
With the earlier auto-skip fix, playback now also just moves past
these rows instead of getting stuck on them.
cc
When the backend rejects a track with TrackNotAvailableOffline (manual
offline mode, no cached copy), playbackService used to surface a toast
and then stop the queue dead. The user had to manually click Next to
continue — in a 20-track playlist with three cached tracks, they'd hit
that wall 17 times.
Now that error path pulls the next track via the same mechanism the
"removed from Qobuz" case already used (Next in the queue, respect the
track's resolved source / isLocal / sampling rate), so the queue rolls
through missing tracks automatically and lands on the next playable
one. If there's nothing left, isPlaying is still cleared so the UI
reflects the stopped state.
Refactor: the auto-skip block was duplicated between the Qobuz-
unavailable branch and would have been duplicated again for the
offline branch, so it's extracted into a single autoSkipToNext helper.
Part of the "offline playlists are available" story.
cc
Two bugs were making the playlists section disappear when the user was
offline, even though the SWR cache already had every regular playlist
from the previous online session:
1. The whole section was wrapped in {#if !isOffline} — an all-or-
nothing kill switch that ignored the fact that individual
features (create, import, drag) already had their own per-
control isOffline gates inside.
2. loadUserPlaylists in offline mode overwrote userPlaylists with
only the pending-playlists list, wiping whatever the SWR cache
restored on mount. So even if the gate were lifted, the list
would be empty the moment the offline effect fired.
Changes:
* Render the playlists section unconditionally. Per-control
network-gated behavior (import button, drag-to-reorder, folder
creation) still honors isOffline individually.
* Offline loadUserPlaylists now keeps the cached regular playlists
(id >= 0) in place and layers pending playlists on top, merging
their local-track counts into the existing map instead of
replacing it.
Opening a cached playlist in offline mode surfaces the tracks the
user had; playback falls back per track to offline cache / local
library / Plex exactly as it already does when partial content is
unavailable.
cc
Plex track artwork in plex_cache_tracks.artwork_path is stored as a
library-relative URI (e.g. /library/metadata/123/thumb/…) which the
Plex server resolves when combined with the configured base URL and
an X-Plex-Token query param. The previous commit was treating it like
a local file path and wrapping it in asset://localhost/, so browsers
couldn't fetch it and the cover rendered broken.
Adds a buildPlexArtworkUrl helper mirroring LocalLibraryView's
implementation — reads qbz-plex-poc-base-url / qbz-plex-poc-token from
userStorage, appends X-Plex-Token, leaves already-absolute URLs
untouched. Used by plexTrackToDisplay when populating albumArt.
cc
Final piece of the Plex-in-playlist plumbing. PlaylistDetailView now
loads the (ratingKey, position) pairs from playlist_plex_tracks and
hydrates metadata from plex_cache_tracks, then interleaves the results
with Qobuz and local tracks by playlist_position so the ordering the
user sees matches what got stored when they added each track.
Backend:
* plex_cache_get_tracks_by_keys(rating_keys) in plex/mod.rs returns
PlexTrack metadata for a set of ratingKey strings, skipping keys
the cache doesn't know about.
* v2_plex_cache_get_tracks_by_keys Tauri command exposing it,
registered in lib.rs.
Frontend:
* New PlaylistPlexTrack interface + plexTracks state alongside the
existing localTracks pipeline.
* loadPlexTracks() does the two-step hydrate (positions, then meta).
Called in parallel with loadLocalTracks and loadPlaylist.
* plexTrackToDisplay() converts to DisplayTrack. The frontend id is
derived from the ratingKey via a djb2 hash offset into a
PLEX_DISPLAY_ID_OFFSET-anchored negative range — stable and never
collides with local (small negative) or Qobuz (positive) ids.
* displayTracks interleaves local + plex + Qobuz tracks by position.
Fast path unchanged when neither source has content.
* hasLocalTracks, totalTrackCount, totalDuration, and the sidebar's
hasLocalContent indicator all pick up Plex rows automatically
because the backend's get_all_playlist_local_track_counts already
merged the plex count in an earlier commit.
Missing plex cache entries (purged, never hydrated) are silently
dropped instead of rendering placeholder rows — the playlist just
shows fewer Plex tracks until the next Plex cache refresh.
Fixes the bug: Plex tracks can now be added from LocalLibrary and
they show up in the playlist detail view, completely decoupled from
the Qobuz playlist API.
cc
Frontend half of the fix. LocalLibraryView no longer gates Plex
tracks out of "Add to playlist" — the whole add pipeline now routes
by source:
* source === 'plex' → v2_playlist_add_plex_track(playlistId,
ratingKey, position). ratingKey comes from track.file_path, which
is where mapPlexTrack stashes it at load time.
* source in {user, qobuz_purchase, qobuz_download} →
v2_playlist_add_local_track(playlistId, localTrackId, position)
(unchanged).
* source === qobuz (Qobuz catalog track) →
v2_add_tracks_to_playlist (unchanged).
Changes:
* uiStore: new playlistModalPlexRatingKeys slot + getter + passed
through openPlaylistModal (4th arg) and closePlaylistModal.
* PlaylistModal: new plexRatingKeys prop, isPlexTracks derived flag,
and branches in handleAddToPlaylist / handleDirectAddToPlaylist /
handleCreateAndAdd that emit v2_playlist_add_plex_track. Duplicate
detection (which only covers Qobuz) is skipped for Plex adds.
tracks_count update for newly-created playlists now treats Plex
like local — 0, since the Qobuz side doesn't own those tracks.
* LocalLibraryView: adds onTrackAddPlexToPlaylist /
onBulkAddPlexToPlaylist callbacks. handleBulkAddToPlaylist and
handleAlbumBulkAddToPlaylist split the selection by source and
emit the right callback per subset. The per-row plex gate at
line 3509 is replaced with source-aware routing.
* +page.svelte: new openAddPlexToPlaylist helper + wired the two
new callbacks into the LocalLibraryView render.
No confusion with Qobuz tracks: Plex rating keys never touch the
Qobuz playlist API, and local-track ids never reach the Plex
command. The two namespaces stay physically separate at every layer.
cc
Wires the qbz-library methods added in the previous commit to Tauri
invoke handlers:
* v2_playlist_add_plex_track(playlistId, ratingKey, position)
* v2_playlist_remove_plex_track(playlistId, ratingKey)
* v2_playlist_get_plex_tracks_with_position(playlistId)
All three lock LibraryState::db, mirror the existing local-track
commands, and go nowhere near the Qobuz API surface — no chance of a
Plex ratingKey being forwarded to /playlist/addTracks by mistake.
Registered in lib.rs invoke_handler next to v2_playlist_add_local_track.
cc
First half of the Plex-tracks-in-playlists fix. Adds a dedicated
playlist_plex_tracks table alongside the existing playlist_local_tracks
so Plex content (TEXT rating key, owned by a remote server) doesn't
collide with the i64 filesystem id used by local_tracks. No foreign
key to plex_cache_tracks — the cache can be purged independently and
the user's intent should survive.
Database methods added:
* add_plex_track_to_playlist(playlist_id, rating_key, position)
* remove_plex_track_from_playlist(playlist_id, rating_key)
* get_playlist_plex_tracks_with_position(playlist_id) ->
Vec<(rating_key, position)>
* get_playlist_plex_track_count(playlist_id) -> u32
get_all_playlist_local_track_counts now merges plex counts into the
same HashMap so the sidebar's hasLocalContent signal stays a single
number and covers Plex-populated playlists without a second round
trip from the frontend.
cc
Custom playlist covers (set from PlaylistDetailView) were visible in
the detail header but not in the sidebar, where the 2×2 collage always
won. The sidebar now shows the custom cover as a single 20×20 tile and
only falls back to the collage / ListMusic icon when no custom cover is
set.
To avoid decoding a multi-MB JPEG for a 20×20 slot, local-file covers
pipe through the existing v2_library_get_thumbnail command — generates
a 500×500 JPEG with the standard on-disk cache so the webview only ever
hands a small resized file to the <img>. Remote URL covers skip the
thumb pipeline and render directly.
Changes:
* PlaylistSettings TS interface gains custom_artwork_path.
* resolveCustomCover(playlistId) returns the thumb URL when available,
the asset://localhost-mapped original as fallback while the thumb
is generating.
* ensureCustomCoverThumbs(settings) fans out to generate + cache the
thumb URL per playlist. Called from both the SWR cache-hydrate path
and the fresh backend fetch.
* Both playlist render sites (folder-playlist + root-playlist) check
the custom cover first before the collage / ListMusic fallback.
cc
Two things:
1. home.recentAlbums and home.continueTracks (the labels shown in the
"Customize Home" modal) used to read "Recently Played" / "Continue
Listening". They now read "Recently Played Albums" / "Recently
Played Tracks" so the modal labels match the section titles that
were renamed earlier.
2. Default limit for continueTracks (now Recently Played Tracks)
changes from 10 → 24. The new grid carousel renders 4×3 = 12
visible cards and can scroll to one more page; 24 fills the two
pages exactly.
i18n updated across all 5 locales.
cc
Replaces the vertical TrackRow list in the Home / For You tabs with a
horizontally-paged grid: 3 rows visible, 4 columns wide on desktop
(responsive down to 3 / 2 / 1 at 1100 / 820 / 540 px breakpoints), up
to 24 tracks total. Scroll arrows advance by a single column at a
time, scroll snap keeps columns aligned, drag-to-scroll is
implicit via the native overflow.
New components:
* TrackGridCard.svelte — per-track card: 48×48 cover with a
play/pause overlay on hover, stacked title / clickable album
name, and a TrackMenu (…) button that appears on hover /
active-track states. Right-click opens the same context menu.
Reuses TrackMenu / favoritesStore so wiring matches the existing
TrackRow behavior.
* TrackGridCarousel.svelte — section container: title, two nav
buttons, and a grid-auto-flow:column viewport that exposes
--col-width / --col-gap / --rows CSS vars tuned by a
ResizeObserver on the viewport. 3 rows is the default; callers
could override.
Integrated into both ForYouTab.svelte and HomeView.svelte — same
key (home.continueListening) drives both surfaces, so the new
presentation is consistent across the Discover tabs.
cc
Renames two home-tab / for-you-tab section titles so their contents
aren't ambiguous once they sit next to each other on the same screen:
* home.continueListening: "Continue Listening" → "Recently Played Tracks"
* home.recentlyPlayed: "Recently Played" → "Recently Played Albums"
Both tabs (Home and For You) read the same keys, so a single change
covers both surfaces. Translated across all 5 locales (en/es/de/fr/pt).
cc
The sidebar already respected the titlebar-owned flag for Discover,
Favorites, Library, and Purchases but not for My QBZ — toggling
"Show My QBZ in titlebar" in Settings duplicated the entry in both
places. Add a myQbzInTitlebar prop plumbed from +page.svelte
(tbNavMyQbz && showTitleBar, same pattern as the others) and gate the
sidebar block on it.
cc
The "PLAYLISTS" section label no longer carries an icon — it returns
to a plain .section-header div. With padding: 0 8px, the text now
starts at x=8px, which matches where the icon containers of every row
below it begin, so the header visually sits on the same vertical line
as the sidebar's icon column.
Cleanup:
* Removed the PlaylistsGlyph import.
* Removed the .section-header-with-icon CSS rule, now orphan
(Library stopped using it when that section got flattened).
The PlaylistsGlyph component and the source SVG under static/ stay
for future use.
cc
Small tightening pass — 22×22 felt bulky in practice. 20×20 keeps the
Cider-style alignment guarantee (all labels share the same x) while
giving the sidebar a slightly denser look.
Touches NavigationItem's iconSize default, the 5 custom-sized
icon-containers in Sidebar (my-qbz, favorites, folder header, popover
header, context menu), and the two PlaylistCoverCollage size={20}
call sites.
cc
Cider-style alignment: the glyph inside the container can be any size
(most lucide nav icons still render at 14×14), but the container slot
is always 22×22 so labels across every sidebar entry land at the same
horizontal position regardless of whether the row shows a lucide icon,
a folder glyph, a collage, or the favorites/my-qbz mask.
Changes:
* NavigationItem iconSize default 14 → 22 (only affects the
container; the caller-supplied icon snippet is unchanged).
* Playlist rows drop the explicit iconSize prop (default now matches
the collage already at 22×22).
* .my-qbz-parent .icon-container, .favorites-nav-item .icon-container
bumped from 14×14 to 22×22.
* Folder header, folder popover header, and "move to folder" context
menu items: wrap the FolderGlyph in an .icon-container div and
size it 22×22. .folder-header also gains height:32px, gap:8px and
padding:0 8px to match NavigationItem.
cc
First pass pushed sidebar labels all the way to --text-primary (same
brightness as track titles in detail views), which read too loud in
actual use. Matches the Settings view's .setting-label tone
(--text-secondary) — brighter than the original --text-muted baseline
but calmer than --text-primary.
Also fixes the Favorites / Library row that was still running on its
old hand-rolled .favorites-nav-item styles (text-muted, no icon
accent): now matches the rest of the sidebar with text-secondary on
the row and accent-primary on the icon container. The
.favorites-nav-item.active override that bumped back up to
text-primary was removed too, since the background tint alone is
enough to mark the active state.
Surfaces touched:
* NavigationItem: .nav-item + .label → --text-secondary
* .favorites-nav-item: same + icon-container gets accent
* .my-qbz-parent: --text-secondary
* .folder-name: --text-secondary
* .folder-popover-header > span: --text-secondary
* .section-header-with-icon > span: --text-secondary
cc
Removes the collapsible Local Library section — header button with
chevron + Browse child row — in favor of a single NavigationItem that
goes straight to the library view. Label is "Local Library"
(library.title), icon is LibraryBig at 14×14 (inherits accent via
NavigationItem's icon-container like every other nav entry).
Also removes the now-unused localLibraryCollapsed state, its entry in
the SidebarCollapseState interface, and the HardDrive icon import
(was only used by the removed Browse row). Persisted collapse state
from previous sessions is read with ?? false so old keys are ignored
gracefully without touching the stored blob.
Side effect: the earlier Library section-header-with-icon tint issue
is moot — the section header that needed custom styling no longer
exists.
cc
Addresses three visual gaps in the sidebar after the earlier brightening
pass:
1. My QBZ parent — icon was inheriting the row's text-muted color
because the custom .icon-container had no color rule of its own. Now
the icon container sets accent-primary and the parent row's label
uses text-primary, matching NavigationItem.
2. Folder rows — the folder glyphs were still muted because .folder-
header / .collapsed-folder-btn / .folder-popover-header all carried
text-muted as their base color, and FolderGlyph paints via
currentColor. Switched each surface's base color to accent-primary
and added explicit text-primary overrides on the actual label spans
(.folder-name, the popover header span) so the labels stay bright.
3. Section headers — PLAYLISTS had no icon at all and Library only had
a chevron. Added glyphs via a new .section-header-with-icon class
that runs accent-primary at the container level (so the SVG picks
up the tint) with a nested span override for the uppercase label.
PLAYLISTS uses a new PlaylistsGlyph.svelte (user-supplied
list-music-player SVG, checked into static/). Library uses
lucide-svelte LibraryBig for now — swap to a custom SVG later if
desired.
cc
Swaps the outline lucide Folder / FolderPlus icons throughout the
sidebar for the user-supplied solid SVGs, with a state-aware variant
on the folder header so expanded folders actually *look* open.
New component: icons/FolderGlyph.svelte — a single file with three
variants (closed / open / new), fill="currentColor" so it picks up the
surrounding --accent-primary color from the nav item.
Placements:
* Folder header (collapsible tree node): open variant when expanded,
closed when not. Reinforces the chevron rotation with a second
visual cue.
* Collapsed-sidebar folder button: closed (there's no expand state
when the whole sidebar is narrow).
* "Move to folder" context-menu entries: closed (list items).
* Folder popover header (opened from the collapsed-sidebar button):
open (the popover IS showing the folder's contents).
* "New Folder" menu item: the new variant (with the + indicator
baked into the glyph).
Source SVGs are checked into static/ for traceability.
cc
Adds Settings > Appearance > "Show playlist cover collages in sidebar"
(on by default, persisted to localStorage as
qbz-sidebar-playlist-collage).
When off, playlist rows in the sidebar fall back to the original
ListMusic icon at its natural 14×14 size — same row alignment as
every other nav item. When on, the 2×2 collage renders as before.
Toggle is reactive via a new subscribePlaylistCollage listener in
sidebarStore, so flipping it applies immediately without a refresh.
Intended for low-end machines where the four extra <img> tags per
visible playlist row (even at _50.jpg, even gated by virtual scroll
and loading="lazy") are still measurable overhead.
i18n: added keys to all 5 locales (en/es/de/fr/pt).
cc
The collage renders at 22x22 total (each tile ~11x11), so Qobuz's
_150.jpg variant (that comes back in images150/300) is roughly 13×
oversampled and ~4× heavier on the wire than needed. Rewrites matching
Qobuz cover URLs to the _50.jpg variant — ~2 KB per tile instead of
~8 KB. A playlist row with 4 tiles drops from ~32 KB to ~8 KB.
Regex only touches URLs ending in `_150.jpg` / `_300.jpg` / `_600.jpg`
(optionally followed by a query string), so non-Qobuz URLs (local
library covers, data URIs) pass through untouched.
cc
Replaces the ListMusic icon on user-playlist rows with a 22x22
2x2 collage of the first four track covers when available, matching
the visual pattern used by Cider and other modern music clients.
Data source: images150 / images300 already come back from
v2_get_user_playlists (qbz-models::Playlist.images150/300). Zero
extra backend calls; the sidebar Playlist interface just gains the
two fields that were being stripped by the narrower TS type.
Resource cost: <img loading="lazy"> + the existing virtual scroll in
the sidebar ensures covers only fetch for playlist rows currently
visible. Playlists nested in collapsed folders are excluded from
visiblePlaylistItems, so their covers never load until the folder
is expanded.
Fallback: if a playlist has no cover images (edge case — playlist
with no tracks, or API omits them), the original ListMusic icon
renders inside the same 22x22 slot to keep row alignment stable.
Components:
- PlaylistCoverCollage.svelte (new): 22x22 grid / single-image /
empty-passthrough based on image count.
- NavigationItem.svelte: adds iconSize prop (default 14) so
playlist rows can expand to 22x22 without affecting the other
nav entries.
- Sidebar.svelte: integrates the collage into both folder-playlist
and root-playlist render paths.
cc
Makes sidebar entries more legible at a glance — item labels now use
--text-primary (was --text-muted, inherited from the button) and the
icon container uses --accent-primary. Matches the visual hierarchy
common in modern music clients (label = content, icon = branded
affordance).
Active state keeps its background highlight but no longer overrides
the label color, since both active and inactive labels are now at the
brightest tier.
cc
Last of the four seekbar surfaces. Previously only NowPlayingBar had
the fix; the immersive-mode compact controls kept showing the old snap
back to pre-seek position during the decoder reinit window.
Adds pendingSeekTime, the 2s lands-near-target detection effect, the
8s safety timeout, and a spinner thumb that renders only while pending
(the immersive design has no thumb by default, so we inject one via
{#if isSeekingPending}).
Part of #335.
cc
Same treatment as NowPlayingBar and ExpandedPlayer — the miniplayer
already deferred onSeek until mouseup, but had no pendingSeekTime, so
the thumb still snapped back to pre-seek position during the 1–2s
decoder reinit window.
Adds pendingSeekTime, a 2s-window effect to clear it once currentTime
lands, an 8s safety timeout, and a spinning-ring variant of the thumb
while the seek is pending. The thumb was display:none in the
miniplayer's minimalist style; it now renders when .seeking is set so
the user gets feedback.
Part of #335.
cc
Brings the large-player seekbar up to parity with NowPlayingBar so long
seeks no longer show the thumb snapping back to the pre-seek position
during the 1–2s decoder reinit window.
Changes:
* Defer onSeek until mouseup (was firing on every mousemove, which
flooded the backend with seek commands and made the visual feedback
noisy).
* Add dragPreviewTime for in-drag thumb position.
* Add pendingSeekTime — thumb stays pinned to target after mouseup
until currentTime lands within 2s of it.
* Spinner on the thumb while pendingSeekTime is active, with an 8s
safety timeout to clear if the seek never lands.
* Time display reads from effectiveTime so the user's intended
position shows through the wait.
Part of #335.
cc
After the user releases the seekbar the backend still needs 1–2s to
reinit the decoder, run the format seek and restart the audio engine
(especially for long jumps on FLAC Hi-Res). The thumb used to snap back
to the pre-seek position during that window, giving the impression the
seek was rejected, then jump to the clicked position when audio
resumed.
Now the thumb stays at the clicked time (pendingSeekTime) and renders
as a spinning ring until currentTime lands within 2s of the target.
Includes an 8s safety timeout so the UI can't be stuck spinning if the
seek never lands (track ended, device lost, etc.).
Part of #335.
cc
Adds InMemorySource — a Symphonia FormatReader+Decoder backed by an
in-memory Cursor — and wires it into the cached branch of the Seek
handler so FLAC Hi-Res and MP3 seeks on LRU-cached / local-library /
offline-cached tracks jump via seek table instead of decoding every
sample from byte 0. Matches the streaming path's behavior, eliminates
the CPU spike during long seeks that was producing audible stuttering
after the streaming seek fix landed.
Falls back to the previous decode_with_fallback + skip_duration path
when Symphonia can't probe the format (rodio-only MP4/AAC container)
or when the native seek itself fails, so nothing regresses for those
formats.
Part of #335.
cc
Replaces skip_duration's decode-every-sample-from-zero loop with
FormatReader::seek(SeekMode::Accurate, SeekTo::Time) on a fresh
IncrementalStreamingSource. For FLAC this jumps via the seek table to
the nearest seek point, then decodes forward to the exact sample —
instead of grinding through millions of samples under the buffered
source's mutex (the cause of post-seek stuttering).
Added IncrementalStreamingSource::seek_to(Duration) which also resets
the decoder, clears the sample queue, and rearms the finished flag so
the source is cleanly re-primed for the iterator.
Non-streaming seeks (cache, offline-cache, local library) still use
skip_duration — they hit in-memory bytes with no lock contention, so
the linear decode is effectively free and there's no reason to change
the path.
Part of #335.
cc
Communicates the seek ceiling that the drag clamp already enforces.
When the mouse hovers the portion of the seekbar past
bufferProgress × 0.9, the cursor switches to not-allowed so the user
gets immediate feedback that aiming there won't land.
Only active while a CMAF stream is still downloading
(bufferProgress < 1). Fully cached, offline, and local-library playback
keep the normal pointer cursor across the full bar.
Part of #335.
cc
While a CMAF stream is still downloading, the backend rejects seeks
past the buffered watermark. This clamps the drag visually to the same
ceiling (bufferProgress × 0.90) so the user can't aim at a position the
backend would bounce. The 0.90 factor matches the safety margin on the
Rust side so frontend and backend agree on the cutoff.
Fully cached, offline, and local-library tracks (bufferProgress === null)
are unaffected and keep full-range seek.
Part of #335.
cc
Relaxes the seek gate added in 2dc1ee9e so seeks that fall inside the
already-downloaded portion of a streaming track succeed instead of being
rejected wholesale. The cutoff is approximated from download fraction ×
track duration with a 10% safety margin (0.90) to cover FLAC/VBR byte
density variance; exact for CBR.
Seeks past the watermark still reject cleanly with a warning log — they
would block the audio thread since skip_duration reads samples
sequentially. Cache, offline-cache, and local-library seeks are
untouched (they don't hit the streaming branch).
Part of #335.
cc
Tightens the streaming-seek handler to the simpler contract signed off
by the user: seek works after a streaming track finishes downloading
(buffered_source.is_complete()), but is rejected cleanly with a warning
log while the download is still in progress. Avoids skip_duration
blocking the audio thread on past-watermark seeks without adding
watermark-tracking complexity.
Cache, offline-cache, and local-library seeks are unaffected — they
reach this handler with current_audio_data Some and skip the streaming
branch entirely.
Fixes part of #335.
cc
The audio thread's Seek handler bailed on `current_audio_data.is_none()`
with a warning and returned. That field is intentionally None during
streaming playback — the bytes live inside current_streaming_source's
Arc<BufferedMediaSource>. Effect: every seek on a streaming track with
the CMAF download still in progress was silently dropped; the UI sent
the command, the player ignored it, and the seekbar snapped back to
whatever position the playback timer had reached (issue #335).
Branch the handler on the live source kind. Streaming: build a fresh
IncrementalStreamingSource from the shared buffered_source (new reader
at byte 0, same downloaded bytes — no re-download), then skip_duration
to the target. Non-streaming path unchanged.
Within-watermark seeks now work. Past-watermark seeks currently block
the audio thread while the decoder waits for more bytes; the clamp to
prevent that lands in the next commit.
cc
Minimal distros (the reporter's Raspberry Pi OS case in issue #331) ship
a trimmed /etc/asound.conf that defines only `!default` and doesn't pull
in the broader alias set most desktop distros provide. A card like the
HifiBerry Digi2 Pro — S/PDIF-only, so its selectable PCM is
`iec958:CARD=sndrpihifiberry,DEV=0` — then fails to open even though
the card is present and usable via the raw `hw:` device.
When CPAL's primary lookup by id returns None AND /proc/asound confirms
the card exists, rebuild the id as `hw:CARD=<name>,DEV=<n>` and retry
the lookup. The raw `hw:` PCM is exposed by the kernel driver for every
registered card, so it's a safe last resort — same card, same channel
routing, just skipping whichever userspace alias layer was missing.
Log both the alias failure and the fallback at warn/info so users can
see what happened without needing to diagnose ALSA config by hand.
Original error path is preserved when no fallback is possible (unknown
id shape, card not in /proc/asound, or hw: equally unavailable).
cc
Device enumeration produces four "CARD=" alias shapes (front:, sysdefault:,
iec958:, hdmi:) but the extractor only handled the first two. When a user
selected an S/PDIF or HDMI device id, the downstream proc_found check
always returned false, so the device-not-found error message dropped to
the generic "disconnected, renamed, or handled by another app" wording
even when /proc/asound showed the card present.
Part of issue #331 — reporter's HifiBerry Digi2 Pro is S/PDIF-only, so
its id is iec958:CARD=sndrpihifiberry,DEV=0, and the missing prefix match
obscured the real cause of the open failure.
This commit is a pure parsing extension; behavior changes only in the
error message accuracy. The hw:/plughw: fallback attempt lands in the
next commit.
cc
Issue #333. handleShuffle was calling handlePlayAll() which hardcoded
playableTracks[0] as the track to start playing. After v2_set_shuffle(true)
the queue was correctly shuffled, but the UI always landed on track 0 —
defeating the point of playlist-level shuffle.
Match the pattern ArtistDetailView / LabelView / LocalLibraryView and
+page.svelte::handleShuffleAlbum already use: pick a random index from
the blacklist-filtered playable set, pass it to handlePlayAll, and let the
existing qbz-player/queue.rs `set_queue` semantics move that index to
shuffle_order[0] so the remaining tracks still cover the full playlist
in random order.
Not touching the Rust-side "CRITICAL FIX" block in queue.set_queue even
though the user suggested removing it — that block is load-bearing for
the other callers that already pass a random index and expect it to
become the shuffle head. Removing it would regress those views.
handlePlayAll now takes an optional `startIndex = 0` so the Play-All
button (unchanged behavior — plays from start) and the Shuffle button
(random start) share the same implementation. Context position is
clamped against the Qobuz-only subset so a random pick landing on a
local track doesn't point past its end.
cc
Drop the `!isLocal` gate on the cast branch so castTrack is invoked for
every source when a cast device is connected. castTrack now knows how to
dispatch to the three source-specific backend commands; the guard was
the only thing keeping non-Qobuz tracks from ever reaching the cast pipeline.
Fixes issue #332: user connects to a DLNA renderer, plays a local file,
expects audio on the renderer, gets silence (audio routes to app's local
ALSA output instead). Same shape as Foobar2000's local-over-DLNA workflow
the reporter compared against.
cc
Previously castTrack hardcoded two calls (v2_cast_play_track for
Chromecast, v2_dlna_play_track for DLNA) which both assume a Qobuz track
id. Extend the signature with an optional source parameter (default
'qobuz' so existing callers keep working) and route to the matching
backend command per protocol × source.
Plex branch pulls baseUrl + token from userStorage the same way
playbackService does for local Plex playback and passes ratingKey as a
string to the backend. Local branch uses trackId as-is (library row id).
Follow-up commit removes the `!isLocal` guard in playbackService so
non-Qobuz tracks actually take this branch.
cc
Plex has the same cast routing problem as local library: the Qobuz-only
play_track commands can't resolve Plex ratingKeys. Add dedicated commands
that fetch bytes through plex_resolve_track_media (existing), proxy them
through the cast media server, and load the URL on the renderer.
Proxying through the media server (rather than giving the renderer a
direct Plex URL) keeps the user's Plex token in-process. Renderers would
need the token to fetch from Plex directly and that's not a surface we
want to expose.
Content-type fallback to audio/mpeg when Plex omits it (transcode path),
with a plex_key_to_u64 namespaced hash (high bit set) so Plex ratingKeys
can't collide with Qobuz/library ids in the MediaServer keyspace.
cc
Companion to v2_dlna_play_local_track. Same bug, same fix applied to the
Chromecast protocol branch: reads library row + file, infers content_type,
registers bytes with the shared media server, loads the URL on the
Chromecast receiver. Reuses the local_track_content_type helper.
User flagged this on the #332 thread — same Qobuz-only assumption in the
Chromecast play command as in the DLNA one.
cc
Mirrors v2_dlna_play_track but for library row ids. Reads the file from
disk, infers content_type from AudioFormat (with extension fallback for
Unknown), registers bytes with the DLNA media server, sends AVTransport
URI + Play to the renderer.
Without this command the cast path had no way to send local audio to a
DLNA device — v2_dlna_play_track resolves the id as a Qobuz track and
fails (404 in offline sessions, wrong-song match in online sessions where
the library row id happens to collide with a real Qobuz track id). Issue
#332 reported the symptom as "DLNA not operational"; logs showed the
renderer connected successfully but never received a SOAP SetAVTransportURI.
Frontend wiring in the cast dispatch + playbackService guard removal lands
in follow-up commits.
cc
Mixtapes & Collections — a second container family alongside Qobuz
playlists. Private to the install, cross-source (Qobuz + LocalLibrary +
Plex), with its own detail view, Add-to picker, bulk ops, per-collection
view prefs, and a Discography Builder for one-click Artist Collections.
Backend:
- New tables mixtape_collections + mixtape_collection_items with
CASCADE + additive migrations.
- New v2_* command surface: CRUD, enqueue_collection,
enqueue_collection_item, skip_to_next_item / previous_item for
album-boundary navigation, mixtape_item_exists for duplicate checks,
custom cover upload / remove.
- Plex branch in resolve_local_album so plex: album keys route to the
plex cache instead of local_tracks.
Frontend:
- Sidebar My QBZ collapsible nav, title bar entry, index + detail views
with virtualization / sticky unified toolbars / size-aware Qobuz
artwork / image cache. Per-collection view prefs (viewMode / sort /
filter) persisted in per-user localStorage.
- Index views get search + sort + kind filter + list-or-grid toggle.
- Add-to-Mixtape wired across ArtistDetail / Search / Label /
PlaylistDetail / LocalLibrary; multi-select + bulk ops for albums.
- Queue / playback fixes bundled: per-album Play for all three sources,
session source round-trip for local/Plex auto-advance, NowPlayingBar
artwork resolver for Plex / local paths, clear-queue includeCurrent
flag, natural-end queue cleanup.
Release notes in docs/release-1.2.8/TAG_DETAILS.md.
cc
Release notes for 1.2.8 explaining Mixtape vs Collection vs Playlist in
user-facing terms: playlists are Qobuz-hosted track lists, Mixtapes are
private ordered cross-source listening sessions, Collections are
private themed album shelves. Covers the Discography Builder, the
Add to Mixtape picker kind rules, custom covers, duplicate-handling
dialog, and the under-the-hood notes for upgraders (new tables, new
v2_* command surface, Plex queue resolver branch).
cc
Consolidates the +page.svelte side of several independent fixes/features
whose non-+page pieces landed in the preceding commits.
- handleMixtapeItemAction: was calling v2_set_queue with { trackIds }
but the command expects { tracks, startIndex } — Tauri rejected the
call so per-album Play from a Collection did nothing. Now routes
Qobuz items through the existing fast path (with blacklist filter +
artwork fallbacks) and local/Plex items through the new
v2_enqueue_collection_item backend command, then resolves the queue
via playQueueIndex(0) + playQueueTrack(trk) so audio actually starts
(v2_play_queue_index only moves the cursor; it doesn't tell the
player to load bytes).
- Session save (saveSessionBeforeClose): includes source on each
PersistedQueueTrack. Without it, a LocalLibrary queue came back as
is_local=true / source=undefined and the auto-advance path routed
local row-ids to v2_play_track (Qobuz).
- Session restore: map session.queue_tracks with a source fallback
(trk.source ?? (trk.is_local ? 'local' : undefined)) so older saved
sessions (pre-source-field) still route correctly. setCurrentTrack
default for source is now 'local' when is_local is true, not
hardcoded 'qobuz'.
- playQueueTrack: artwork goes through resolveQueueTrackArtwork so a
raw Plex /library/... thumb path or a bare local filesystem path
becomes a valid src for NowPlayingBar instead of a 404.
- Title bar: isMyQbzInTitlebar state + showMyQbz prop passed to
TitleBarNav so the new My QBZ entry can be toggled from Settings.
cc
Mirrors the Discover / Library / Local Library / Purchases title-bar
entries. Button opens a hover dropdown with Mixtapes and Collections;
label comes from myQbzNavStore so a sidebar rename propagates.
- titlebarNavStore: new myQbz flag with getter/setter/migration.
isTitlebarNavEnabled() includes it in the enabled-any check.
- TitleBarNav: new showMyQbz prop + CassetteTape button with a
dropdown menu (Mixtapes / Collections); shared open/close mechanics
with the other nav dropdowns so only one is open at a time.
- SettingsView: new Toggle between Library and Purchases, disabled
when the title bar is hidden or the system title bar is active
(matches the other toggles in the section).
Wiring in +page.svelte ships with the next commit.
cc
Root Collections and Mixtapes pages had only a title + New button + a
grid of cards. Added the same .list-controls vocabulary the detail view
uses so users see a consistent control surface across the feature.
- Search: matches name + description.
- Sort dropdown with asc/desc toggle on re-click: Position (manual),
Name, Items, Recently updated.
- Kind filter on CollectionsView only (All / Collections / Artist
Collections). MixtapesView omits it — one kind means the dropdown
would be noise.
- View-mode segmented toggle (list / grid). Grid keeps the existing
208px cards; list is a dense row with 48px mosaic cover + name +
kind label + count.
- Empty-results state when search/filter returns zero.
- Preferences persisted globally (per user) under
collections-index-prefs / mixtapes-index-prefs — viewMode, sortBy,
sortDir, kindFilter round-trip across sessions. searchQuery stays
transient per-session.
i18n (en / es-MX / de / fr / pt):
- collections.searchPlaceholder / sortByItems / sortByUpdated /
kindFilterAll / kindFilterCollection / kindFilterArtistCollection
- mixtapes.searchPlaceholder
- nav.myQbz (used by the upcoming title bar entry)
cc
Collection / Mixtape detail, Playlist detail, and Playlist manager all
got sluggish with 50+ items — too many <img> tags, full 600px Qobuz
covers decoded for tiny tiles, and two-tier sticky headers with visible
gaps between them. Straightened all of it with patterns we already had
in the codebase; no new deps.
MixtapeCollectionDetailView:
- Fixed-height windowed virtualization for list/grid modes (LIST_ROW_HEIGHT 56,
GRID_ROW_HEIGHT 240). Top/bottom spacer divs reserve scroll height.
- Row height forced to 56px (was variable 52-60), so the scrollbar's
scrollHeight matches the actual end of the content and the user can
reach the last row without stutter.
- The scroll-reset effect now reads listAnchorOffsetTop via untrack so
mid-scroll ResizeObserver ticks don't re-fire the reset and snap
scroll back; RO no longer observes the list anchor itself (its size
changes every scroll tick and is irrelevant to its offsetTop).
- Expanded-mode auto-fetch is gated to visible items — no more 60
concurrent backend calls on mode switch.
- Sticky toolbar wraps search + sort + filter + select + view-mode as
ONE block; box-shadow -8px/+8px extends the opaque background over
the scroller's 8px padding-top gap and any next-element margin seam.
- Qobuz artwork URLs downsized (_50 / _150) for list/grid thumbnails
instead of the stored _600.
PlaylistDetailView:
- Track controls + batch-controls + column header wrapped in one
.tracks-sticky-header inside .track-list. Single sticky instead of
two at top:0 and top:56 with a visible row peeking through the gap;
containing block is .track-list (tall as virtual content) so the
toolbar no longer disappears when scrolled past the .view-transition
wrapper's height: 100%.
- Cover <img> routed through use:cachedSrc + loading=lazy / decoding=async.
PlaylistManagerView:
- .controls sticky with box-shadow -8px/+12px to cover the padding-top
gap and bottom margin; folder-icon <img> tags (grid / list / tree)
now use:cachedSrc with convertFileSrc + lazy/async.
Shared components:
- QualityBadgeStatic: new component, visually 1:1 with QualityBadge's
full mode but no hardware-status polling, no downgrade arrow, no
plughw tooltip — used in list rows where every item advertises its
own stored quality and shouldn't react to now-playing state.
- CollectionMosaic + PlaylistCollage: size-aware Qobuz URL picker
(50/150/300/600 based on rendered tile size) and use:cachedSrc +
lazy/async on every cell. 20 mosaics × 4 cells at 140px used to
pull 80 × 600px JPEGs; now they pull 80 × 150px JPEGs through the
shared image cache.
cc
- AddToMixtapeModal: accepts an array of items (was single), gains a
duplicate pre-check panel with Add anyway / Skip duplicates / Cancel,
hides the + Collection button for non-album items (Collection shelves
can't hold a bare track or nested playlist).
- AlbumCard: selectable / selected / onToggleSelect props — corner
checkbox overlay that replaces the click-to-open behavior when
selection mode is on. Same pattern ArtistDetailView already used for
popular tracks.
- VirtualizedAlbumList: pass-through for the select-mode props +
forwards albumId / year / trackCount so cards render consistently in
the grid.
- LocalLibraryView: new SquareCheckBig toggle in the Albums tab; bulk
handlers (PlayNext / PlayLater / AddToMixtape / AddToPlaylist) route
each selected album through fetchAlbumTracks so local + Plex items
resolve correctly. BulkActionBar mounted at the bottom of the albums
grid.
- ArtistDetailView + LocalLibraryView: root-view padding now
'padding: 8px 8px 100px 18px' and .back-btn margin-top 8px to match
the AlbumDetailView / FavoritesView standard.
cc
Two fixes that together clear the LocalLibrary auto-advance desync:
audio played a different track than the NowPlayingBar showed, and the
cover showed a '?' placeholder for local/Plex tracks.
- sessionService.ts: PersistedQueueTrack gains an optional source
field so qobuz | local | plex round-trips through session_store.
Without this, the restore path rebuilt the queue with is_local=true
but source=undefined, and downstream callers that defaulted
undefined-source to 'qobuz' routed v2_next_track results to
v2_play_track — Qobuz then resolved library row ids to whatever
random track shared the id.
- queueArtwork.ts: new resolveQueueTrackArtwork() turns a CoreQueueTrack
artwork_url (http, file://, bare Plex /library/... path, or bare
filesystem path) into something the WebView can actually fetch.
Previously a raw Plex thumb path hit localhost:1420 and 404'd;
local paths tried to load as if relative URLs.
- playbackService.ts: both auto-skip fallback paths now resolve next
track artwork through the helper before handing off to playTrack.
cc
Backend:
- New v2_enqueue_collection_item(collectionId, position, mode) routes a
single collection item through the existing ProdItemResolver — same
path v2_enqueue_collection uses, scoped to one position. Supports
replace / play_next / append modes. Unlocks per-album Play for local
and Plex items from the Collection detail view (previously only Qobuz
had a working fast-path there).
- enqueue.rs: resolve_local_album now detects plex: prefixes and routes
to plex_cache_get_album_tracks instead of querying local_tracks, so
Collections with Plex albums actually enqueue end-to-end.
- repo.rs: list_collections now hydrates items for every collection so
the item count in CollectionsView / MixtapesView is real, not 0.
- mixtapeCollectionsStore.ts: bindings updated for new + changed
commands (add_item with allow_duplicate, item_exists, custom cover
upload/remove).
cc
Full rework of MixtapeCollectionDetailView. Groups of changes:
Play + UX correctness
- Play / Shuffle actually start audio: after enqueueCollection fires,
fetch the first queue track via playQueueIndex(0) and hand off to
playTrack() so the audio pipeline kicks in (bridge.play_index alone
only updates queue state, never triggers playback).
- Loading spinner swaps Play / Shuffle icons for LoaderCircle while
the enqueue + track load is in flight — covers the noticeable
delay on large collections.
- Canonical scroll pattern (width/height/overflow-y auto).
- Detail-view header Play / Shuffle / More buttons now use the
global .action-btn-circle family instead of bespoke pill styles.
Item row chrome
- Source indicators: canonical monochrome mask pattern
(plex-mono.svg / hdd.svg / qobuz-logo.svg) tinted --accent-primary.
- Quality column uses the full QualityBadge component.
- Live-resolve per item: fetch v2_library_get_albums +
v2_plex_cache_get_albums once on load so each row knows its true
kind (plex vs qobuz_download vs qobuz_purchase vs user) and can
show Plex album art via buildPlexArtworkUrl.
- Qobuz items fetch v2_get_album in parallel to populate quality
data (Qobuz-streaming items don't persist bit-depth / sample-rate
at add-time).
- Clickable title → onOpenItem; clickable artist subtitle (Qobuz
only) → onOpenArtist runtime-resolves via search.
- Row hover reveals a play overlay on the artwork.
Row context menu (⋯)
- Play / Play Next / Add to queue entries.
- Change release type → nested submenu with Album / EP / Single /
Live / Compilation and "Use auto-detected" reset. Uses the
shared releaseTypeOverridesStore so the override also shows in
DiscographyBuilder and vice-versa.
- Type column shows the effective release type for album items.
Controls row
- Search (album title / subtitle + track titles when expanded).
- Sort (Position / Name / Year / Tracks, asc/desc toggle).
- Filter with Type + Source sections (multi-select Source).
- Reset clears every filter + sort.
- Select mode toggle (SquareCheckBig).
- View-mode segmented group (List / Grid / Expanded).
View modes
- List: canonical row listing (no chevron, no inline tracks).
- Grid: AlbumCard-like tiles with corner-checkbox selection.
- Expanded: auto-loads every album's tracks via v2_get_album
(Qobuz), v2_library_get_album_tracks (local — with title+artist
re-resolve fallback for re-indexed libraries) or
v2_plex_cache_get_album_tracks (Plex album_key prefix). Tracks
render as real TrackRow instances.
Selection + BulkActionBar
- Selected positions tracked separately from view-mode.
- BulkActionBar below the list with Play Next, Add to Queue, Add
to Playlist, Add to Mixtape/Collection, Remove from Collection.
Custom cover + header actions
- Hero mosaic + grid tiles honor the user-uploaded cover.
- Header ⋯ menu: Upload cover / Clear custom cover.
i18n
- collectionDetail.* keys across all 5 locales: itemActions,
changeReleaseType, removeItem, cover{Uploaded,UploadFailed,
Removed,RemoveFailed}, selectItem, sort, filter, sortBy*,
filterAll, viewMode, view{List,Grid,Expanded}, searchPlaceholder.
cc
Handlers + callback wiring in the root view layer:
DiscographyBuilder
- onOpenAlbum → handleAlbumClick (Qobuz) or selectLocalAlbum (local)
so clicking a candidate album navigates to its detail.
Mixtape / Collection detail
- onOpenItem for album / track / playlist rows.
- onOpenArtist runs handleOpenArtistByName: v2_search_artists(name)
and navigates to the top match — collection items only store an
artist name, not an id.
- onPlayItem / onPlayItemNext / onAddItemToQueueLater wired to a
new handleMixtapeItemAction that fetches the item's tracks
(Qobuz album path) and sets / appends the queue. Non-Qobuz
item types toast a "not available yet" message.
- Bulk variants (onBulkPlayNext / onBulkPlayLater /
onBulkAddToPlaylist) iterate through selected items.
- onPlayTrackFromItem (expanded-view TrackRow Play): fetches the
parent album and jumps to the picked track's index.
- onPlayTrackNext / onPlayTrackLater: fetch via v2_get_track and
call v2_add_tracks_to_queue_next / v2_add_tracks_to_queue for a
single Qobuz track.
Adds selectLocalAlbum + MixtapeCollectionItem to the existing
imports.
cc
- back-to-top-global button z-index 50 → 200 so no view's sticky
footer (Collection detail BulkActionBar, DiscographyBuilder footer,
etc.) can park on top of it.
- AddToMixtapeModal now receives items={} (array) instead of
item={} (single) after the store + modal signature moved to the
bulk-capable shape.
cc
Two related UX fixes in handleClearQueue + the setOnTrackEnded branch:
- handleClearQueue now passes includeCurrent=!isPlaying. When the
user hits Clear while nothing is actively playing, the call flows
down to v2_clear_queue, which stops + wipes the current slot too —
no more stale track lingering in NOW PLAYING across restarts.
- setOnTrackEnded's "queue ran out + no repeat" branch now also
calls clearQueue({includeCurrent: true}) after stopPlayback(). The
track that just finished is already off; wiping the current index
stops it from being re-parked by any subsequent queue snapshot.
cc
Both AlbumCard call sites (artist-albums column + VirtualizedAlbumList)
were rendering AlbumCard without albumId, so its built-in
handleAddToMixtape never fired and AlbumMenu never rendered the entry.
Pass albumId (+ year + trackCount for modal metadata).
Album detail hero gains an Add to Mixtape/Collection action-btn-circle
(CassetteTape icon) next to Play / Shuffle / Select, firing
openAddToMixtape for the selected album.
cc
BulkActionBar gains onAddToMixtape + onRemoveFromCollection props
(CassetteTape + Trash2 icons, reusing existing common.* i18n keys).
AddToMixtapeModal now takes items: AddToMixtapeItem[] instead of a
single item. Single-item callers pass a 1-element array; bulk callers
(BulkActionBar in the Collection detail) pass many. Modal loops
over all items on pick and shows a bulk-count toast when more than
one was added.
Track / playlist items can only be added to Mixtapes, not Collections
(Collections hold whole albums by design). When any incoming item is
a track or playlist, the picker filters the visible target list to
mixtapes only AND hides the + Collection footer button so the user
can't land in an invalid destination.
cc
New customCoverUrl prop — when set, renders a single full-size image
instead of the 2x2/3x3 collage. CollectionsView + MixtapesView
compute it via convertFileSrc(col.custom_artwork_path) so the grid
tiles show the user's uploaded cover in the list as well as in the
detail view.
cc
Lets users pick any image (png/jpg/jpeg/webp) as a Mixtape's or
Collection's cover. Mirrors v2_library_set_custom_album_cover:
source → artwork cache dir → resize to 1000x1000 via image crate →
store path in mixtape_collections.custom_artwork_path.
- v2_mixtape_upload_custom_cover(id, source_path): validates extension,
copies + resizes, stamps the resulting path, deletes the previous
cover file after the DB write succeeds.
- v2_mixtape_remove_custom_cover(id): nulls the column + deletes the
on-disk file.
- Both registered in lib.rs.
- Frontend store: uploadCustomCover / removeCustomCover wrappers that
also patch collectionsStore optimistically so UIs update without a
full reload.
Uses mixtape::repo::get_custom_artwork (added in the previous commit)
to find the previous path before overwriting.
cc
Major rework of the Discography Builder:
- Local library tracks finally show up: fix camelCase args for
v2_library_get_albums (includeHidden / excludeNetworkFolders) and
serialize the Qobuz fetch before the local one so artistName is
populated before filtering.
- Include Plex-cache albums too (v2_plex_cache_get_albums with a
2-second retry after cold-cache) so Plex collections show in the
candidate list.
- Album titles + artist subtitles are now clickable (onOpenAlbum
callback routes Qobuz→handleAlbumClick, local→selectLocalAlbum).
- New Tracks + Type columns; Type shows Album / EP / Single / Live /
Compilation (color-tinted). Classifier uses Qobuz release_type +
group_type when present, falls back to track-count heuristic.
- User can override the release type per row via a popover on the
Type cell; overrides persist in per-user localStorage via the new
releaseTypeOverridesStore (sidecar — never written to Qobuz or
the library DB).
- Canonical source indicators (plex-mono.svg / hdd.svg / qobuz-logo.svg
as CSS masks tinted with --accent-primary).
- Full QualityBadge component (bit-depth + sample-rate + format),
dropping the compact + overrides experiment.
- Header check/uncheck-all with indeterminate state.
- Footer clears the back-to-top gap; scroll via the canonical
width/height/overflow-y pattern from ArtistDetailView.
- Alternate rows keep the sub-item connector next to the checkbox
(no indent), matching the visual signal without shifting columns.
cc
TrackMenu / AlbumMenu already expose onAddToMixtape; the following
sites just never passed it through:
- ArtistDetailView popular-tracks TrackMenu
- SearchView track results TrackMenu
- LabelView track-list TrackMenu
- PlaylistDetailView hero AlbumMenu (add the whole playlist)
Also refactors ArtistDetailView's follow button icon to use a CSS
mask (background-color: currentColor) instead of a hardcoded invert +
hue-rotate filter chain that only produced purple — so it adapts to
whatever accent color the current theme sets.
cc
Frontend plumbing that lets callers pass {includeCurrent: true} all
the way down to v2_clear_queue so the backend can wipe the now-playing
slot + stop playback when appropriate.
cc
QueueManager::clear previously always preserved the current track;
plus bridge.play_index only mutates queue state, never triggers
audio. Result: when the user hit Clear Queue with nothing playing,
a stale track stayed in NOW PLAYING across app restarts.
- QueueManager::clear(keep_current: bool) — existing callers pin to
true (legacy behavior). New test covers the false path.
- QbzCore::clear_queue / CoreBridge::clear_queue take the flag through.
- v2_clear_queue accepts an optional includeCurrent flag from the
frontend; when true, calls bridge.stop() first so the audio pipeline
actually stops, then wipes the current slot too.
- qconnect sync path + qbzd daemon pin keep_current=true (no behavior
change).
cc
Plex exposes album release year (parentYear / parentOriginallyAvailableAt)
and genre (inline attr or nested <Genre tag>). The parser dropped both;
LocalLibrary and DiscographyBuilder could never show a Plex album's year
even though the metadata was available upstream.
- Extend PlexTrack + PlexCachedAlbum structs with year/genre.
- Parse both in parse_track_block, including the ISO-date fallback.
- ALTER TABLE plex_cache_tracks ADD COLUMN year INTEGER / genre TEXT
(idempotent).
- INSERT + SELECT paths + the album aggregation carry the new columns.
cc
list_collections only read the mixtape_collections row and left
items=[], so every consumer (sidebar, Collections/Mixtapes grid,
AddToMixtapeModal) saw zero-item counts for every collection. Load
items per collection at the end of the listing, matching what
get_collection already does.
cc
Leftover from Task 5.3 when the mixtape-detail route rendered a
"Full detail view is built in Phase 6" hint. Phase 6 replaced the
placeholder with MixtapeCollectionDetailView; the .hint selector
has had no consumer since then. svelte-check flagged it; removed.
cc
The CollectionsView shipped with a secondary CTA that was stubbed to
a console.log pending an artist picker we never built. Shipping a
dead button is worse than not having it. Artist Collections remain
buildable via the circular action button on ArtistDetailView (next
to Follow / Radio) — that path actually works. A future iteration
can add the picker and reintroduce the CTA if it proves useful.
cc
Two issues flagged by user review:
1. MixtapeCollectionDetailView and DiscographyBuilderView shipped with
a custom bordered .back-btn (icon + "Back" text in a rounded box),
visually inconsistent with the rest of the app. The canonical back
button across ArtistDetailView / PurchaseAlbumDetailView /
MusicianPageView / BlacklistManagerView / AwardView is borderless,
ArrowLeft 16px + translated text, text-muted color with hover to
text-secondary. Both views now use that exact pattern.
2. DiscographyBuilderView threw Svelte error each_key_duplicate for
qobuz0060254702534 because the Qobuz artist-page endpoint can return
the same album under multiple release_groups (albums + compilations
most commonly). Those duplicates landed in the same group's
alternates list, and the {#each} keyed by source+source_item_id
detected the dupe. buildGroups() now dedupes candidates by
(source, source_item_id) on entry.
cc
The button shipped as a rounded-rectangle pill with icon + text,
visually inconsistent with the circular icon-only siblings (favorite,
radio, network). The brief was "zero pills, reuse existing
conventions"; this swaps to a 44x44 circle matching .favorite-btn
exactly, with a lucide LibraryBig icon and the title attribute
carrying the "Build Artist Collection" label for hover.
cc
The view had two $effects — one of them read $collectionsStore AND the
local `collection` state AND wrote `collection` via object spread on
every run. Svelte 5 detected the self-re-triggering cycle and threw
effect_update_depth_exceeded, which froze every interaction on the
detail view after a collection was created.
The store-sync effect was redundant: every mutation handler in this
component already calls loadCollection() after success, so the local
`collection` state stays current without that effect. Removing it
eliminates the loop without losing any behavior. collectionsStore
import dropped accordingly.
cc
Cross-source artist discography builder. Fetches the artist's Qobuz
releases (via v2_get_artist_page) and LocalLibrary albums by artist
name (via v2_library_get_albums filtered client-side), normalizes
titles to group variants of the same album, ranks each group by
quality_score (bit_depth × 10M + sample_rate_hz + format_bonus), and
renders a primary row + indented alternate rows per group.
Compilations detected heuristically (best of / greatest hits /
anthology / ...) and unchecked by default. Default selection: primary
of each non-compilation group. User can check alternates to include
them too. Order by release date / title / manual (preserves input
order). Submit creates an artist_collection with
source_type='artist_discography' + source_ref=artistId and enqueues
items in display order.
ArtistDetailView gains a "Build Artist Collection" action button
(LibraryBig icon, secondary style, rendered only when callback prop is
present). Entry point from CollectionsView left as-is (artist picker
deferred). Added 'discography-builder' to ViewType union.
Toast keys toast.collectionCreated / toast.collectionCreateFailed
added to all five locale files (en, es, de, fr, pt).
cc
Two more entry points, wired to the same global modal trigger store.
TrackRow's context menu gains the action for both Qobuz and local
tracks. Playlist rows expose it only for Qobuz playlists — local
playlists are narrowed by the backend resolver (spec §3.1) and
would fail at enqueue time.
cc
Adds a shared modal-trigger store + mounts AddToMixtapeModal once at
the page level. AlbumCard exposes the action via its overflow menu;
AlbumDetailView exposes it as a secondary action button in the top
action row. Both build an AddToMixtapeItem from the album's source /
id / title / artist / artwork / year / track count and invoke
openAddToMixtape(), which the global modal consumes.
cc
Full detail page with a CollectionMosaic header, title, kind tag,
description, item count, and three primary actions (Play / Shuffle /
overflow). Overflow menu exposes Rename, Edit description, Toggle
play mode, Convert kind (disabled for artist_collection), and Delete
with a confirmation dialog.
Item table columns: # | Item (artwork + title + subtitle) | Type |
Source | Quality | Tracks | Year | ⋯. Type column uses a lucide icon
+ uppercase letter-spaced label (no pill). Source reuses <SourceBadge>.
Quality reuses the shared QualityBadge compact component.
Inline per-row overflow menu offers Remove. "Play next" and full
item drag-reorder are stubbed for future work.
+page.svelte's Task 5.3 placeholder is replaced with this component;
onBack returns the user to the matching grid view (Mixtapes or
Collections) based on the collection's kind. getCollection import
removed from +page.svelte (now only used inside the detail component).
cc
Shared modal for "Add to Mixtape/Collection" entry points. Lists the
user's collections sorted by recency, with a kind icon + name + item
count + kind tag per row. Clicking a row calls addItem; dedup toast
when backend returns false. "+ Mixtape" / "+ Collection" footer opens
an inline create-panel that creates then adds the current item in one
shot. Search filters by name (case-insensitive substring). ESC closes
the create-panel first, then the modal.
Adds a single new i18n key (common.addedToMixtapeOrCollection) across
5 locales for the success toast.
cc
Replaces the pre-5.B "Coming soon" placeholders with the real grid
views. Adds an inline create-modal (name + kind toggle) that calls
createCollection and navigates straight to the new collection's
detail placeholder — which itself stays minimal until Phase 6
ships MixtapeCollectionDetailView. Collections' "Build Artist
Collection" secondary CTA is wired to a TODO comment pending
Phase 8 DiscographyBuilderView. Drops dead .coming-soon CSS.
cc
Near-identical grid layouts for the two sections under My QBZ. Both
subscribe to collectionsStore, filter by kind, and render cards with
CollectionMosaic covers. Empty states center the mosaic placeholder
with kind-specific lucide icons and CTAs.
CollectionsView also exposes a "Build Artist Collection" secondary
CTA alongside the regular "+ New Collection" primary. The label
"ARTIST · <name>" is used on artist_collection cards so they are
distinguishable at a glance from plain Collections.
cc