Commit Graph
100 Commits
Author SHA1 Message Date
vicrodh dfd8f84afd chore(flake): bump to v1.2.8
Update qbzVersion, srcHash (via nix-prefetch-url of the tag tarball) and
npmHash (captured from nix build with fakeHash).

cc
2026-04-21 20:38:49 -06:00
vicrodh 0b481e5332 chore(release): bump version to 1.2.8 "My QBZ"
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
2026-04-21 19:25:24 -06:00
Victor RH 230f2dc390 Merge pull request #341 from vicrodh/dependabot/cargo/src-tauri/rustls-webpki-0.103.13
chore(deps): bump rustls-webpki from 0.103.10 to 0.103.13 in /src-tauri
2026-04-21 19:17:34 -06:00
vicrodh 51ff1c4f37 fix(playlist-detail): plex in queue with source marker so auto-advance works
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
2026-04-21 15:35:31 -06:00
vicrodh f9b7290e90 fix(playlist-detail): route plex tracks through v2_plex_play_track
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
2026-04-21 15:22:41 -06:00
vicrodh f9a7891802 feat(titlebar): default Discover, Library, Local Library, My QBZ, Search to titlebar
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
2026-04-21 15:01:16 -06:00
vicrodh 1d173a2cb1 feat(downloads): default show_in_library to true for new installs
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
2026-04-21 15:01:02 -06:00
vicrodh af1f23718c feat(immersive): default panel FPS to 30 across the board
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
2026-04-21 15:00:48 -06:00
vicrodh a06b5fd8c3 style(mixtape-detail): homologate hero to PlaylistDetailView
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
2026-04-21 14:45:45 -06:00
vicrodh 18549eeb2d feat(mixtape-detail): grid card artwork spinner while image bytes fetch
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
2026-04-21 14:31:01 -06:00
vicrodh 1ded75b35e perf(image-cache): normalize Plex token out of cache keys for hits across sessions
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
2026-04-21 14:24:51 -06:00
vicrodh c5db3895a0 fix(mixtape-detail): drop content-visibility from grid-card to stop scroll-back black flash
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
2026-04-21 14:24:31 -06:00
vicrodh 63a1ea34bd perf(mixtape-detail): preload artwork + show sync cache hits to stop placeholder flash
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
2026-04-21 14:16:54 -06:00
vicrodh 3edc6ca28f perf(mixtape-detail): native content-visibility on grid cards and item rows
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
2026-04-21 13:46:27 -06:00
vicrodh e873fd4800 perf(mixtape-detail): compute grid row height from actual card width
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
2026-04-21 13:38:17 -06:00
vicrodh b132c2bf64 fix(mixtape-detail): bypass virtualization in expanded mode so scroll reaches the end
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
2026-04-21 13:36:43 -06:00
vicrodh 39abb14c05 feat(playlist-detail): consume backend is_network_mount flag
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
2026-04-21 13:29:14 -06:00
vicrodh 1e13447921 feat(library): detect network-mounted tracks at index time
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
2026-04-21 13:27:31 -06:00
vicrodh 54a7179627 fix(playlist-detail): treat Plex and network-mounted tracks as offline-unavailable under forced offline
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
2026-04-21 13:17:08 -06:00
vicrodh 636ece0873 fix(playlist-detail): filter unreachable tracks from Play All / Next / Later
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
2026-04-21 13:02:14 -06:00
vicrodh df78ab38a8 fix(playlist-detail): grey-out offline-unavailable tracks + keep nav menu
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
2026-04-21 13:00:33 -06:00
vicrodh a1c2318735 fix(playback): auto-skip tracks unplayable in offline mode
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
2026-04-21 12:51:20 -06:00
vicrodh f4122625fa fix(sidebar): keep playlists visible in offline mode
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
2026-04-21 12:46:51 -06:00
vicrodh 27712ddaf7 fix(playlist-detail): render Plex artwork via Plex base URL + token
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
2026-04-21 12:28:34 -06:00
vicrodh 5d2e6c2e23 feat(playlist-detail): render Plex tracks added to a Qobuz playlist
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
2026-04-21 12:06:48 -06:00
vicrodh d5ff51aba0 feat(playlist-modal): route Plex tracks through the dedicated add path
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
2026-04-21 11:38:25 -06:00
vicrodh 753487582a feat(commands): expose Plex-in-playlist Tauri commands
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
2026-04-21 11:32:22 -06:00
vicrodh 13ecec3b17 feat(library): add playlist_plex_tracks table and CRUD for Plex-in-playlist
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
2026-04-21 11:27:51 -06:00
vicrodh 4a16567fac feat(sidebar): honor the user's custom playlist cover, with an optimized thumb
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
2026-04-21 11:13:20 -06:00
vicrodh 24e224e802 i18n: align Customize Home labels with the new section titles + raise default
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
2026-04-21 10:59:53 -06:00
vicrodh dda5506c06 feat(home): render Recently Played Tracks as a 4×3 grid carousel
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
2026-04-21 10:51:02 -06:00
vicrodh 6d8f0f4011 i18n: clarify Home / For You section titles
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
2026-04-21 10:40:59 -06:00
vicrodh 8b90ac7245 fix(sidebar): hide My QBZ section when it lives in the titlebar
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
2026-04-21 10:31:48 -06:00
vicrodh 0896d8cb93 style(sidebar): drop the Playlists header icon
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
2026-04-21 10:24:07 -06:00
vicrodh feb750e231 style(sidebar): shrink icon slot from 22×22 to 20×20
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
2026-04-21 10:17:10 -06:00
vicrodh e9a6b223a0 style(sidebar): unify icon container to 22×22 so every label shares one x
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
2026-04-21 10:07:17 -06:00
vicrodh 54988d8dff style(sidebar): drop label tier from text-primary to text-secondary
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
2026-04-21 10:01:23 -06:00
vicrodh e47480b30f refactor(sidebar): flatten Local Library collapsible into a single nav item
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
2026-04-21 09:58:02 -06:00
vicrodh 4ef7242e4c style(sidebar): accent-tint parent icons and add glyphs to section headers
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
2026-04-21 09:48:01 -06:00
vicrodh d71bf2334d style(sidebar): replace outline folder icons with solid two-state glyphs
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
2026-04-21 09:38:51 -06:00
vicrodh e98bfd62d8 feat(settings): opt-out toggle for sidebar playlist cover collage
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
2026-04-21 09:35:31 -06:00
vicrodh ff5d143011 perf(sidebar): request 50x50 variant for playlist cover collage tiles
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
2026-04-21 09:25:25 -06:00
vicrodh 17cee63142 feat(sidebar): show 2x2 cover collage for playlist entries
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
2026-04-21 09:24:25 -06:00
vicrodh fa21db5216 style(sidebar): brighten nav labels and accent-tint nav icons
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
2026-04-21 09:22:09 -06:00
vicrodh f5cb7bb7b4 fix(ui): apply pending-seek pattern to immersive PlayerControlsCompact
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
2026-04-20 21:38:07 -06:00
vicrodh 1e9861c351 fix(ui): apply pending-seek pattern to MiniPlayerFooter seekbar
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
2026-04-20 21:36:30 -06:00
vicrodh 448df9c59f fix(ui): apply pending-seek pattern to ExpandedPlayer seekbar
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
2026-04-20 21:34:41 -06:00
vicrodh 8833174f21 fix(ui): keep seekbar thumb on target position with spinner during seek
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
2026-04-20 21:29:07 -06:00
vicrodh dd443ba822 perf(player): use Symphonia native seek for cached-track seeks
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
2026-04-20 21:25:17 -06:00
vicrodh 11d01a2075 perf(player): use Symphonia native seek for CMAF streaming seeks
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
2026-04-20 21:15:14 -06:00
vicrodh bdc901a88a fix(ui): show not-allowed cursor past streaming watermark
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
2026-04-20 21:08:32 -06:00
vicrodh 3b8b457836 fix(ui): clamp seekbar drag to buffered zone during streaming
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
2026-04-20 21:06:54 -06:00
vicrodh f58ee31ed7 fix(player): allow seek within buffered zone during CMAF streaming
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
2026-04-20 21:06:00 -06:00
vicrodh 2dc1ee9e9f fix(player): reject seek during active CMAF streaming
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
2026-04-20 20:52:03 -06:00
vicrodh 7d9a847a87 fix(player): honour seek during active CMAF streaming
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
2026-04-20 20:28:48 -06:00
vicrodh 927fabbd78 fix(alsa): fall back to hw:CARD=<name>,DEV=<n> when aliased id can't open
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
2026-04-20 20:12:39 -06:00
vicrodh 05f0d69fcb fix(alsa): recognise iec958:/hdmi: prefixes in extract_card_name_from_device
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
2026-04-20 20:09:27 -06:00
vicrodh 38edf2d012 fix(playlist): shuffle button starts on a random track, not canonical track 0
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
2026-04-20 19:35:28 -06:00
vicrodh d1895f83d0 fix(cast): route local + plex tracks through cast when a device is connected
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
2026-04-20 18:36:51 -06:00
vicrodh c978e38aef feat(cast): dispatch castTrack on source (qobuz/local/plex) × protocol
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
2026-04-20 18:35:36 -06:00
vicrodh 42d432469a feat(cast): v2_{dlna,cast}_play_plex_track for Plex tracks over DLNA/Chromecast
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
2026-04-20 18:33:46 -06:00
vicrodh 76158c81d3 feat(cast): v2_cast_play_local_track for local-library tracks over Chromecast
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
2026-04-20 18:26:58 -06:00
vicrodh 75146d66a9 feat(cast): v2_dlna_play_local_track for local-library tracks over DLNA
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
2026-04-20 18:23:18 -06:00
vicrodh 7c7e819165 Merge branch 'feature/mixtapes-collections' into pre-release
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
2026-04-20 17:50:04 -06:00
vicrodh c09213a0ee docs(release-1.2.8): Mixtapes & Collections tag notes
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
2026-04-20 17:49:27 -06:00
vicrodh 68e76a203e fix(ui): wire per-album play + session source restore + NowPlayingBar artwork + My QBZ titlebar
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
2026-04-20 17:49:17 -06:00
vicrodh 260f147621 feat(titlebar): add My QBZ to title bar nav
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
2026-04-20 17:48:59 -06:00
vicrodh 739d5b0f7e feat(collections): index toolbar (search/sort/filter/view) + i18n
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
2026-04-20 17:48:49 -06:00
vicrodh 62355f8b64 perf(mixtape+playlist): virtualize + unified sticky headers + small artwork
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
2026-04-20 17:48:31 -06:00
vicrodh ec52997c02 feat(library): album multi-select + bulk ops + root-view padding
- 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
2026-04-20 17:48:04 -06:00
vicrodh 4b6d8173af fix(queue): preserve source on session save/restore + resolve queue artwork
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
2026-04-20 17:47:51 -06:00
vicrodh 23f56b178a feat(mixtape): v2_enqueue_collection_item + Plex branch + repo hydration
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
2026-04-20 17:47:38 -06:00
vicrodh 93eef97b4b feat(mixtape): Collection/Mixtape detail view rewrite
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
2026-04-19 22:47:05 -06:00
vicrodh 921d5b455c feat(mixtape): wire Mixtape/Collection + DiscographyBuilder navigation
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
2026-04-19 22:45:16 -06:00
vicrodh 4e618dd5b7 fix(ui): back-to-top z-index + AddToMixtapeModal items prop
- 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
2026-04-19 22:36:15 -06:00
vicrodh 7d2340a978 fix(queue): clear stale now-playing after Clear + natural end
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
2026-04-19 22:35:00 -06:00
vicrodh 578a934c20 i18n: DiscographyBuilder column labels + release types + track count
Keys added to all 5 locales (en/es/de/fr/pt):
- discographyBuilder.col{Year,Type,Album,Tracks,Source,Quality}
- discographyBuilder.releaseType.{album,ep,single,live,compilation}
- discographyBuilder.{checkAll,uncheckAll,typeOverrideHint,typeOverrideReset}
- common.trackCount (reused across DiscographyBuilder + Collection
  detail tracks column tooltip)

Mexican Spanish convention for es.json.

cc
2026-04-19 22:31:24 -06:00
vicrodh 0c49f7153f feat(library): expose Add to Mixtape for albums in LocalLibrary
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
2026-04-19 22:23:40 -06:00
vicrodh 5974276bea feat(mixtape): bulk Add to Mixtape + kind restriction in picker
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
2026-04-19 22:23:28 -06:00
vicrodh 40fa372611 feat(collections): CollectionMosaic honors user-uploaded covers
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
2026-04-19 22:23:14 -06:00
vicrodh 1a223c2a85 feat(mixtape): custom-cover upload + remove commands
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
2026-04-19 22:22:14 -06:00
vicrodh 32e4eff504 feat(discography): builder overhaul — scroll, columns, selection, type override
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
2026-04-19 21:20:11 -06:00
vicrodh ee9b695e32 feat(mixtape): wire Add to Mixtape across missing menu sites
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
2026-04-19 21:18:51 -06:00
vicrodh 837f2ac649 feat(queue): thread includeCurrent through commandRouter + queueStore
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
2026-04-19 21:18:26 -06:00
vicrodh f01fc17383 feat(queue): clear-queue includeCurrent flag stops + clears now-playing
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
2026-04-19 21:17:09 -06:00
vicrodh de96afccd5 feat(plex): parse and cache track year + genre
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
2026-04-19 21:15:46 -06:00
vicrodh 2f769d38cd fix(mixtape): hydrate items in list_collections
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
2026-04-19 21:14:28 -06:00
vicrodh b244d36b5a feat(assets): add monochrome hdd.svg for local-source indicators
Used as a CSS mask in DiscographyBuilder and Collection/Mixtape detail
to render the "local files" source icon tinted to the accent color.

cc
2026-04-19 21:14:12 -06:00
vicrodh 32c6727e2f chore: remove unused .detail-placeholder .hint CSS rule
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
2026-04-19 02:41:44 -06:00
vicrodh 8f02c542fd fix(collections): remove dead "Build Artist Collection" CTA
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
2026-04-19 02:40:20 -06:00
vicrodh d244981376 fix(mixtape): align Back buttons with app convention; dedup discography candidates
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
2026-04-19 02:32:45 -06:00
vicrodh 2eb60c16a7 fix(artist): make Build Artist Collection button match sibling icon-circle style
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
2026-04-19 02:28:01 -06:00
vicrodh 7c58425d6c fix(mixtape): remove effect-update-depth loop in MixtapeCollectionDetailView
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
2026-04-19 02:24:45 -06:00
vicrodh 2460467e48 feat(mixtape): DiscographyBuilderView for Artist Collections
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
2026-04-18 20:57:02 -06:00
vicrodh b7458522b3 feat(mixtape): Add to Mixtape/Collection on TrackRow and playlist rows
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
2026-04-18 20:43:17 -06:00
vicrodh 6615f6bbe3 feat(mixtape): "Add to Mixtape/Collection" on AlbumCard and AlbumDetailView
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
2026-04-18 20:38:22 -06:00
vicrodh 9068b2bf14 feat(mixtape): MixtapeCollectionDetailView
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
2026-04-18 20:33:36 -06:00
vicrodh 9d699ffe41 feat(mixtape): AddToMixtapeModal picker
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
2026-04-18 20:25:19 -06:00
vicrodh 8f5c05d3cb feat(mixtape): wire MixtapesView and CollectionsView into the page router
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
2026-04-18 20:20:36 -06:00
vicrodh 5b3afa882b feat(mixtape): MixtapesView and CollectionsView grid pages
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
2026-04-18 20:15:26 -06:00