Commit Graph
100 Commits
Author SHA1 Message Date
vicrodh ec1bf38791 test(visualizer): wrap SamplesBuffer args in NonZero for rodio 0.22
rodio 0.22 tightened SamplesBuffer::new to require NonZero<u16> for
channels and NonZero<u32> for sample_rate, but the visualizer test
fixtures still passed bare integer literals — so the qbz-audio test
binary stopped building. Wrap both call sites in NonZero::new(...)
.unwrap() so cargo test -p qbz-audio compiles again.

Pure test-only change; runtime visualizer code was already migrated.

cc
2026-04-24 19:17:40 -06:00
vicrodh 4105d597a8 fix(i18n): add missing toast.playingAlbum key across all locales
Two $t('toast.playingAlbum', { values: { count } }) call sites in
+page.svelte (collection/mixtape detail play paths) were printing the
raw key because the translation was never added. The `|| fallback`
idiom after each call never fired either — $t() returns the key
itself on miss, which is truthy.

Add an ICU plural entry covering both callers: count=1 (detail-view
play button on an album) reads as "Playing album", count>1 (bulk
play-all) reads as "Playing {count} tracks". Mirrored across en, es,
de, fr, pt.

cc
2026-04-24 16:22:09 -06:00
vicrodh e05ed48d60 fix(views): close titlebar gap on pinned sticky headers
Both PlaylistDetailView's .tracks-sticky-header and ArtistDetailView's
.jump-nav opened a visible gap between the titlebar and the sticky
bar once pinned — scrolled content bled through as a blurred strip.
Two causes, both hit here:

- Subpixel/compositing rounding in WebKitGTK 2.50+ left a few px
  transparent at the pinned top edge. Promoting each bar to its own
  compositing layer via `will-change: transform; transform: translateZ(0)`
  mirrors the same workaround imageCacheService applies to <img>.

- Even with a compositing layer, the scroll container above the pin
  can briefly expose bg before the bar's fill kicks in. Add a solid
  `box-shadow: 0 -32px 0 0 var(--bg-primary)` so the bg extends
  upward and stays covered regardless of any remaining offset. For
  .jump-nav this composes with the existing downward drop shadow.

Also set `background: var(--bg-primary)` on .playlist-detail and
.artist-detail (the scroll containers) and bump .track-controls'
padding-top to -2px (CSS clamps to 0 but documents the intent), so
the toolbar sits flush against the bar border without the 8px
breathing-room the unpinned state had.

cc
2026-04-24 16:19:39 -06:00
vicrodh 580bdaf6bc perf(sidebar): warm playlist tile cache after initial load
cachedSrc gives each playlist row's tiles the backend disk cache on
the first scroll that reveals them — good on cold runs but still
visibly staggered if the user scrolls quickly after launch.

Fire a preloadImages batch from a $effect keyed on userPlaylists and
the collage toggle: as soon as the list lands (from SWR cache, a fresh
v2_get_user_playlists, or an offline refresh), every playlist's first
four Qobuz images get routed through the same _50.jpg downscale used
at render time and queued into the shared in-memory resolvedUrls map.
Fire-and-forget, dedupes against already-resolved URLs, skipped when
the user opted out. The net effect is that cachedSrc hits a warm map
by the time the <img> elements mount, so the sidebar scrolls without
the loading-placeholder flash that used to bridge the first backend
round-trip.

cc
2026-04-24 15:55:46 -06:00
vicrodh 11edb2b05d perf(sidebar): route collage tiles through the shared image cache
PlaylistCoverCollage rendered tiles with plain <img src={url}>, which
bypassed v2_get_cached_image (the backend disk cache used everywhere
else for Qobuz imagery) and the in-memory dedup map in
imageCacheService. Second-launch tile fetches hit the Qobuz CDN over
the wire when they should have been served from disk, and playlists
that happen to share a cover each paid for their own invoke round-trip.

Switch the two <img> sites to use:cachedSrc so tiles go through the
same pipeline the larger PlaylistCollage uses:
- on-disk cache persists across sessions
- resolved URLs are memoised per-session; duplicate covers fold to one
  backend call
- the action also applies the will-change/translateZ compositing hint
  that works around WebKitGTK 2.50+ texture eviction during scroll,
  removing the occasional placeholder flash on long sidebar scrolls

asset:// custom-cover URLs short-circuit inside cachedSrc so local
thumbs keep their existing path.

cc
2026-04-24 14:57:35 -06:00
vicrodh 7ac5c7e8a0 perf(audio): skip CPAL probe of virtual ALSA PCMs during enumeration
build_cpal_device_map iterated every PCM that cpal's ALSA host exposes
(dmix:, route:, surround51:, pulse, null, …) but only five name shapes
are ever looked up downstream: default, sysdefault:CARD=*, and
{front,hdmi,iec958}:CARD=*,DEV=*. Dropping the rest avoids holding
Device handles we never query and, combined with the new error
handler, removes another source of spurious libasound probes on
systems where PipeWire owns the hardware exclusively.

cc
2026-04-24 13:40:42 -06:00
vicrodh 134808c880 feat(audio): route libasound errors through log::debug
libasound's default error handler prints directly to stderr. During
device enumeration probes, PCMs that wrap dmix or surround routes emit
benign errors ("unable to open slave", "no matching channel map") that
cluttered the app log without indicating a real problem.

Install a process-wide snd_lib_error_set_handler shim from
AudioBackendFactory::create_backend (idempotent via std::sync::Once)
that forwards the file/line/function/err/fmt tuple to log::debug. The
messages stay available with RUST_LOG=debug and disappear during
normal runs.

Adds alsa-sys 0.3 as a direct Linux-only dependency on qbz-audio
(previously transitive via alsa/cpal). The handler is declared
non-variadic and transmuted into libasound's variadic function pointer;
ignoring the unused varargs is ABI-compatible on every platform we
support.

cc
2026-04-24 13:40:09 -06:00
vicrodh 7e74d9f9cd Merge branch 'chore/internal/typescript-6' into pre-release
Bumps typescript from 5.9.3 to 6.0.3. Validated on the feature branch
via svelte-check (0 errors), vite production build, and vitest
(no new regressions vs 5.9.3).

cc
2026-04-24 12:54:13 -06:00
vicrodh 1e77642d7a chore(deps-dev): bump typescript from 5.9.3 to 6.0.3
Validated on this branch:
- npm run check (svelte-check): 0 errors, 41 warnings (identical to 5.9.3)
- npm run build (vite production): succeeds in ~57s
- npm run test:run (vitest): same 2 pre-existing failures as pre-release,
  no new regressions

Replaces closed dependabot PR #347.

cc
2026-04-24 12:48:53 -06:00
vicrodh a569833daf fix(titlebar): accept full-height-rounded in controlsShape prop
The windowControlsStore ButtonShape union and the TitleBar CSS already
support full-height-rounded (Klassy default), but the TitleBar prop
type was narrower, causing a type error at the one call site in
+page.svelte. Widen the prop union to match.

cc
2026-04-24 12:28:44 -06:00
vicrodh 5395fd79bb fix(awards): rename loadingMore prop to isLoadingMore for grid
VirtualizedFavoritesAlbumGrid expects isLoadingMore; AwardAlbumsView
was passing {loadingMore} shorthand, triggering a svelte-check error.

cc
2026-04-24 12:28:44 -06:00
vicrodh 14b72b0582 chore(deps): bump rand from 0.8.5 to 0.8.6
Fixes GHSA-cq8v-f236-94qc (low: rand unsound with custom logger using
rand::rng()). Transitively bumped across both src-tauri and crates
workspaces via cargo update.

cc
2026-04-24 12:05:04 -06:00
vicrodh 19344c9037 chore(deps-dev): bump vitest from 4.1.4 to 4.1.5
cc
2026-04-24 11:43:59 -06:00
vicrodh 110ecf11cc chore(deps-dev): bump vite from 8.0.8 to 8.0.10
cc
2026-04-24 11:43:02 -06:00
vicrodh 674771b216 chore(deps): bump notify-rust from 4.14.0 to 4.16.0 in /src-tauri
cc
2026-04-24 11:40:51 -06:00
vicrodh d871d924aa chore(deps): bump rustls-webpki from 0.103.12 to 0.103.13 in /crates
cc
2026-04-24 11:38:52 -06:00
vicrodh 40189baade chore(deps): bump tauri-plugin-single-instance from 2.4.0 to 2.4.1 in /src-tauri
cc
2026-04-24 11:38:42 -06:00
vicrodh 592c70928f chore(deps): bump uuid from 1.23.0 to 1.23.1 in /src-tauri
cc
2026-04-24 11:38:32 -06:00
vicrodh 777a060fd5 chore(deps): bump ashpd from 0.13.8 to 0.13.10 in /src-tauri
cc
2026-04-24 11:38:24 -06:00
vicrodh a2e5f8567a chore(deps): bump open from 5.3.3 to 5.3.4 in /src-tauri
cc
2026-04-24 11:38:15 -06:00
vicrodh 60283f0a2a chore(deps): bump openssl from 0.10.75 to 0.10.78 in /src-tauri
cc
2026-04-24 11:37:51 -06:00
vicrodh c0ab9f54cb feat(multiselect): dock BulkActionBar at list end via IntersectionObserver
Replaces the always-fixed positioning with a sentinel-driven dock/float
mechanism. A zero-height sentinel rendered before the bar marks the
bar's natural flow position at the end of the selectable list. An
IntersectionObserver flips the bar between two states:

- Floating: position fixed, bottom: var(--player-bar-height), with
  left/width tracked from the sentinel's bounding rect so the bar
  matches the column width (no longer overlaps the album sidebar).
- Docked: in-flow static position right below the last item once the
  sentinel scrolls into view, so the bar settles below the list
  instead of covering content below.

Also removes the 8px gap between the floating bar and the player bar.

cc
2026-04-24 11:37:19 -06:00
vicrodh a6eefae6b3 feat(multiselect): float BulkActionBar above player bar
Switch .bulk-bar from sticky to fixed positioning so it sits above the
player bar (bottom: player-bar-height + 8px) instead of clipping against
the scroll container. Matching adjustments for placement-top and a
higher z-index (40) to clear other overlays.

cc
2026-04-24 11:37:19 -06:00
vicrodh 1ee9cf8f80 feat(multiselect): albums tab select-all, shift-range, Ctrl/Cmd+A
Finish out #342 by giving the Albums tab the same treatment as the
Tracks tab. The plumbing mirrors what VirtualizedTrackList got:

- VirtualizedAlbumList now owns the shift-click anchor. Its
  onAlbumToggleSelect widens to accept the MouseEvent/KeyboardEvent,
  and a new onAlbumToggleSelectRange callback fires with every
  album id between the previous click and the shift-click. The
  ordered id list is derived from the same groups input the grid
  renders from, so the range matches what the user sees.
- LocalLibraryView gets addAlbumsToSelection (merge helper) plus
  albumSelectAllState / toggleAlbumSelectAll, the tri-state
  select-all checkbox shown next to the SquareCheckBig toggle
  while albumSelectMode is active, and a Ctrl/Cmd+A listener
  scoped to that mode. Reuses the .select-all-checkbox styles
  added for the Tracks tab.

Range selection in the Albums tab grid is the last piece the issue
called out, so #342 is fully addressed on the app side now.

cc
2026-04-24 11:37:19 -06:00
vicrodh fdfa02fe08 feat(multiselect): tracks tab select-all, shift-range, Ctrl/Cmd+A
LocalLibraryView's Tracks tab runs through VirtualizedTrackList,
so the shift-click anchor lives in the list component. Here we
only have to wire the batch-add callback:

- addTracksToSelection(ids) merges a range into selectedTrackIds
  and is passed to VirtualizedTrackList as onToggleSelectRange.
  The per-track onToggleSelect path stays as it was.
- Add the select-all checkbox NorinB asked for in #342: it shows
  up in .track-controls only while trackSelectMode is active, uses
  the familiar none/partial/all tri-state, and toggles between
  "clear selection" and "select every track in the library". The
  checkbox operates on the full `tracks` array rather than what's
  visible in the virtualized viewport, which is the behaviour he
  wanted (one click = everything).
- Ctrl/Cmd+A while in select mode does the same as clicking the
  checkbox when it's empty. Listener is scoped to the select-mode
  lifecycle and skipped when focus is in an input (search bar).

Small CSS for the new inline checkbox so it sits in the controls
row without looking out of place.

cc
2026-04-24 11:37:19 -06:00
vicrodh dab73cb528 feat(multiselect): shift-click + Ctrl/Cmd+A across remaining views
Finish rolling out the #342 pattern to the last four select-mode
views. Three of them follow the same shape as AlbumDetailView, but
with view-specific wrinkles:

- ArtistDetailView: popular tracks use a hand-rolled row (not
  TrackRow); updated the row onclick / onkeydown and inline
  checkbox wrapper to forward the MouseEvent.
- PlaylistDetailView: selection is keyed by a composite
  "trackId:isLocal" string (playlists mix Qobuz and local tracks),
  so the range logic still operates on an index array but the ids
  passed into the Set are the composite keys produced by
  getTrackKey(). The existing drag-to-reorder path uses a separate
  selectedTrackKeys set and is left untouched.
- MixtapeCollectionDetailView: items are keyed by their numeric
  position within the collection, so the range calculation runs
  over the ordered positions array. Used in both the grid and list
  view-mode branches.

FavoritesView is the first view that renders its tracks inside
VirtualizedTrackList, so I pushed the shift-anchor down into the
list component. VirtualizedTrackList now keeps its own ordered
track-id list (computed from `groups` + `buildAlbumSections` so
the order matches what the virtual items emit) and a
lastSelectedIndex anchor. On shift-click it resolves the range
against that ordered list and emits the added ids via a new
`onToggleSelectRange` callback. Plain toggles still go through the
existing `onToggleSelect` so callers that don't opt in keep their
current behaviour unchanged. FavoritesView wires
onToggleSelectRange to a simple "add these ids to the set" merge,
which is exactly what we want for range selection.

Each view also gains the Ctrl/Cmd+A listener, gated on its own
select-mode flag and on isSelectAllShortcut (no firing while
focus is in a search input).

cc
2026-04-24 11:37:19 -06:00
vicrodh 7219b530a8 feat(multiselect): shift-click + Ctrl/Cmd+A in Qobuz list views
Apply the AlbumDetailView pattern to the remaining select-mode
views over Qobuz track lists (issue #342):

- FavQView, TopQView, WeeklySuggestView, DynamicSuggestView:
  same shape as AlbumDetailView — filteredTracks is the range
  basis, onToggleSelect now forwards (id, index, event).
- LabelView: same logic, but the row here is a hand-rolled div
  (not TrackRow). Updated the row onclick / onkeydown and the
  inline checkbox wrapper so the MouseEvent with shiftKey reaches
  toggleMultiSelect. Dropped the native onchange handler on the
  checkbox in favour of preventDefault-on-click; the reactive
  `checked={selected}` keeps the visual state in sync.

Each view also gains a Ctrl/Cmd+A listener that is only attached
while multi-select mode is active and is ignored when focus is in
an input (via isSelectAllShortcut).

cc
2026-04-24 11:37:19 -06:00
vicrodh a0d60841bc feat(multiselect): shift-click range + Ctrl/Cmd+A in AlbumDetailView
Implement the pattern for issue #342 in AlbumDetailView as the
reference for the other select-mode views:

- Track `lastSelectedIndex` as an anchor, reset when leaving
  multi-select mode.
- On toggle, if shiftKey is set and we have an anchor, expand the
  selection to include every track between the anchor and the
  clicked index (standard file-manager semantics: range is additive,
  the clicked track becomes the new anchor).
- A `keydown` listener bound while multi-select mode is active
  grabs Ctrl/Cmd+A and selects every track. Gated by
  isSelectAllShortcut so it's a no-op when focus is in an input.

The select-mode toggle button stays — shift-click operates inside
the already-active mode, per the product decision on the issue.

cc
2026-04-24 11:37:19 -06:00
vicrodh ab029eb351 feat(multiselect): add shift-range utility and propagate MouseEvent
Lay the groundwork for shift-click range selection in multi-select
views (issue #342):

- Add src/lib/utils/multiSelect.ts with applyShiftRange() and
  isSelectAllShortcut() so the logic is defined once and each view
  just wires it to its own selection set.
- TrackRow: the native checkbox was using onchange which fires a
  plain Event and drops shiftKey. Move toggle handling onto the
  wrapper div with preventDefault on the input so the click event
  (with shiftKey) reaches onToggleSelect. The checkbox stays
  visually driven by the reactive `selected` prop.
- AlbumCard: widen onToggleSelect signature from `() => void` to
  `(e: MouseEvent | KeyboardEvent) => void` and forward the event
  from both the card click and the Enter/Space keyboard path.

Existing callsites that pass `() => toggleFoo(id)` keep working
because a zero-arg callback is assignable to the wider signature.

cc
2026-04-24 11:37:19 -06:00
vicrodh 1a4aa26b8b docs(api-keys): reflect proxy-based architecture, remove .env instructions
.env.example is now a placeholder pointing to the hosted Cloudflare
Workers proxy. README updates the API Proxy section accordingly and
.gitignore adds CLAUDE.md and AGENTS.md.

cc
2026-04-24 10:23:57 -06:00
vicrodh 3d42214775 docs(build): credit #354 and document future AVX2 pattern
Expand the .cargo/config.toml comment to reference both reports of
the SIGILL-at-launch bug: issue #353 (Sandy Bridge on Linux) and
discussion #343 (2012 MacBookPro9,1 / Ivy Bridge on macOS). The
macOS Intel case was diagnosed independently by @afonsojramos, who
opened PR #354 with the same fix landed earlier in b54574a7.

Also codify the forward-looking note afonsojramos suggested in that
PR: if a future hot loop needs AVX2/BMI2, the correct pattern is
per-function #[target_feature] with runtime dispatch (what the `aes`
crate already does via cpufeatures), not a global target-cpu bump
that silently breaks older hardware. This way the rationale is in
the file the next time someone is tempted to raise the baseline.

No code change, comment-only.

cc
2026-04-23 22:46:21 -06:00
vicrodh b54574a786 fix(build): drop x86-64-v3 baseline so pre-Haswell CPUs don't SIGILL
Issue #353 reports v1.2.8 dying with "Illegal instruction (core
dumped)" at launch on Sandy Bridge (Intel HD 2000, 2011). The crash
is not in CMAF decrypt — it's the process never reaches user code.
Root cause: .cargo/config.toml set target-cpu=x86-64-v3 which is
the Haswell (2013) microarch level. LLVM then freely emits AVX2,
BMI2, FMA, LZCNT and MOVBE across the whole binary, including the
Rust/Tauri startup path. Any pre-Haswell x86_64 CPU (Sandy Bridge,
Ivy Bridge, AMD Bulldozer/Piledriver) SIGILLs on the first of those
instructions, long before CMAF decrypt or the UI mounts.

The x86-64-v3 baseline was added together with +aes,+ssse3 thinking
it was required to activate AES-NI. It is not. AES-NI is an
orthogonal ISA extension; target-feature=+aes alone is what gets
the aes crate to compile in the AES-NI code path, and its
cpufeatures::new! runtime CPUID check then dispatches to hardware
or software per-process. So the 40x CMAF decrypt speedup (22s ->
<500ms on a 208 MB HiRes bundle) came from +aes, not from v3.

Drop target-cpu=x86-64-v3. Keep target-feature=+aes,+ssse3:
  - Modern CPUs with AES-NI still get the hardware decrypt path.
  - CPUs without AES-NI get the software fallback via cpufeatures
    runtime dispatch — slower but correct, no crash.
  - SSSE3 has been in every x86_64 CPU since 2006 (Core 2).
  - Pre-Haswell CPUs stop SIGILLing at launch.

What we lose: some AVX2/BMI2 auto-vectorization in general codegen
(stdlib, symphonia decode paths). CMAF decrypt — the actual
bottleneck we were optimizing — is unaffected.

cc
2026-04-23 08:43:42 -06:00
vicrodh 20787766f7 fix(themes/stratego): bright crimson accent for foreground legibility
The initial Stratego palette used a deep crimson (#a0161d) as accent:
perfect AAA contrast with bone-white button text, but invisible when
used as foreground color on dark backgrounds — and the app has ~300
components that do exactly that (card labels, tab underlines, volume
fill, subscription chip, link text, progress markers, etc.).

Swap for a bright crimson (#ed2f3d) + near-black button text. Matches
the mask's face-stripes and the mandala glow instead of the shadowed
mandala. Accent now:
- reads as foreground text on every bg tier (AA 4.25-4.75:1)
- still AAA on large button text (4.75:1) with near-black label
- keeps hover brighter (#f74a58) and active deeper (#c41e2a)

Addresses user feedback on the first Stratego pass where card labels
("ARTIST"/"COLLECTION"), the account-tier chip under the username,
and the volume-slider fill were unreadable.

cc
2026-04-21 21:29:54 -06:00
vicrodh 0292c8650d feat(themes): add Stratego (imperial crimson + bone white, AAA contrast)
Dark theme inspired by a samurai mask + war mandala. Palette pulled
from the source image: near-black canvas, bone-white ivory for text
(matches the mask teeth and kanji), imperial crimson as accent, antique
gold for warnings (dragon crest horns), steel-grey for borders.

Contrast verified AAA on the critical pairs:
- accent-primary #a0161d on btn-primary-text #fcfaf2 -> 7.55:1
- text-primary #ece6d6 on bg-primary #0a0a0b -> 15.7:1
- text-secondary #b5afa0 on bg-primary -> 8.7:1

Registered in SettingsView picker next to Iscariot, translations added
to all five locales (proper name, not localized).

cc
2026-04-21 21:08:30 -06:00
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