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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
.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
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
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
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
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
Mixtapes & Collections release. Bumps all version references
(Cargo.toml, tauri.conf.json, package.json, snapcraft.yaml, both
Cargo.lock, package-lock.json), adds the 1.2.8 entry to the Flatpak
metainfo, and sets the About modal codename to "My QBZ".
cc
Revises the previous commit's plex-in-queue filter. Turns out the
backend queue is a passive data structure — it doesn't play tracks
itself. Playback on queue advance goes through the frontend's
onTrackEnded callback:
nextTrack() → BackendQueueTrack → playQueueTrack → playTrack({..., source})
…and playTrack already dispatches source='plex' to v2_plex_play_track
on line 299. BackendQueueTrack has an optional `source?: string` field
for exactly this reason; the previous build just wasn't populating it
for playlist tracks.
Changes in buildQueueTracks:
* Plex tracks are no longer filtered out of the queue.
* Each queue entry now carries an explicit source: 'plex' | 'local'
| 'qobuz' marker derived from the DisplayTrack's flags. That's
what resolvePlaybackSource reads to pick the right playback
command when auto-advance fires.
* localTrackIds (used by offline-mode availability) still only
contains filesystem-local ids — plex ids shouldn't land in there
because that Set is validated against local_tracks.
Direct-click plex playback still works via handleTrackClick's isPlex
branch (commit f9b7290e). The remaining limitation — Play Next / Play
Later for plex tracks — is still a no-op because those enqueue-single
paths haven't been taught to pass source='plex' through to the
backend's add-to-queue; separate follow-up.
cc
Plex tracks added to a Qobuz playlist weren't playable:
* Clicking the row hit onTrackPlay → playTrack with no source arg →
backend treated the id as a Qobuz track id and rejected it with
"invalid track id for remote handoff: -1000017964" (the negative
plexDisplayId UI-only offset was the id we sent).
* Auto-advancing from the previous (local) track mapped the plex
DisplayTrack's negative id through Math.abs into the local-track
queue, which then looked 1_000_017_964 up in local_tracks and
errored with "Track not found".
Changes:
1. plexTrackToDisplay now uses Number(ratingKey) as id when the
ratingKey parses as a positive integer, matching the shape Plex
backend / LocalLibraryView already use (PlexCachedTrack.id =
playback_track_id(rating_key) = the numeric rating_key for the
common case). The negative-offset hash stays as the fallback for
non-numeric rating keys — those still need UI-unique ids but are
rare enough that no-play is an acceptable degradation.
2. isLocal stays true so the Qobuz context-builder and blacklist
filter keep their existing semantics; the new isPlex flag drives
routing at the dispatch sites.
3. PlaylistDetailView.Props gains onPlexTrackPlay (track) → void.
handleTrackClick checks track.isPlex first and dispatches through
it; handleTrackPlayNext / handleTrackPlayLater no-op on plex for
now (backend queue doesn't understand plex yet).
4. removeTrackFromPlaylist checks track.isPlex first and calls
v2_playlist_remove_plex_track with ratingKey: String(track.id) so
the "Remove from playlist" menu item works on plex rows too.
5. +page.svelte gets handleDisplayPlexTrackPlay — mirrors
handleLocalTrackPlay's plex branch (playTrack with source='plex',
isLocal:false). Wired via onPlexTrackPlay on the PlaylistDetailView
render.
6. buildQueueTracks filters plex out entirely. The local-track queue
path in the backend looks ids up in local_tracks; plex ratingKeys
aren't there and the mismatch was the source of the auto-advance
"Track not found" toast. Plex tracks keep their direct-click
playback; gapless auto-advance across plex will require backend
queue awareness (deferred).
Known limitation carried forward: Play Next / Play Later / auto-
advance across plex tracks are no-ops until the backend queue learns
to cross-reference plex_cache_tracks. Direct click on a plex row
plays correctly.
cc
New installs open with the core nav items already in the custom
titlebar so the app feels "populated" right after login. Purchases is
excluded from the default set because it's an opt-in Qobuz feature
that not every account uses — keeping it off avoids a dead button for
most users.
Existing users are untouched. Both stores read localStorage in their
init* function and overlay the persisted values on top of these
defaults, so anyone who deliberately moved nav items back to the
sidebar (or never cared and just got the old all-false defaults)
keeps whatever they have.
Defaults now:
titlebarNavStore: discover / favorites ("Library") / library
("Local Library") / myQbz → true. purchases → false.
searchBarLocationStore: titlebar.
No effect on users who toggled off the custom titlebar itself — the
config still exists but isTitlebarNavEnabled() is a no-op for them.
cc
Offline-cached Qobuz tracks should appear in the Local Library on a
fresh install — the whole point of caching is to have them available
alongside local files. Users who set the preference explicitly (on or
off) are untouched: the INSERT OR IGNORE in DownloadSettingsStore
only writes the default row when no row exists yet.
Three coordinated changes inside download_settings.rs:
* DownloadSettings::default().show_in_library: false → true
* Schema DEFAULT on the column: 0 → 1 (covers rows added post-
migration from code paths that skip the explicit INSERT)
* The INSERT OR IGNORE seed row now interpolates
default_settings.show_in_library instead of hardcoding 0, so the
code default and the SQL default stay in sync.
cc
The immersive panels defaulted to 15fps (linebed was the sole
override at 60). 15 is visibly choppy for audio-reactive motion; 60
is overkill for everything except the linebed terrain. 30fps is the
middle ground — perceptually smooth for the slow-evolving visuals
while keeping CPU/GPU headroom for machines that didn't ask for 60.
* DEFAULT_FPS 15 → 30
* linebed's 60 override removed (30 also reads fine for it)
getPanelFps still prefers the stored qbz-immersive-fps-<panelId> key
in userStorage when set, so users who manually tuned a panel higher
or lower keep their value. The new default only kicks in for panels
with no stored preference — i.e. new installs and panels the user
never touched.
cc
The Collection / Mixtape detail hero was the outlier across app
detail views — 240px cover and 40px title against the 186px cover
and 24px title every other detail view (playlist, album) uses. That
made those two feel visually louder and taller than everything else.
Aligns with .playlist-header sizing:
* cover 240 → 186
* title 40px → 24px / line-height 1.2
* kind tag 10px accent → 12px text-muted (matches .playlist-label)
* gap 24 → 32
* description line-height 1.4 to match
header-info stays column+flex-end so the metadata hugs the bottom
edge of the cover (same alignment as playlist / album detail views).
cc
Pure-CSS spinner on .grid-artwork-wrap::after, visible through the
transparent <img> element while it's mid-fetch. Once the image's
bytes paint, the opaque pixels cover the pseudo-element — no JS load
handler, no per-card reactive state, no extra re-render on load.
Placeholder cards (no artwork URL at all) hide the spinner behind
their own solid background via the lifted .grid-artwork-placeholder
rule.
Addresses the user's "add a spinner while caching" ask for the grid
view; the dark-square-during-fetch look was the bit that still felt
heavy even after preload + sync cache hits landed.
cc
Plex thumbnail URLs carry X-Plex-Token (and sometimes
X-Plex-Client-Identifier) as a query parameter. Those rotate each
time a token refresh lands, so the *exact same* /library/metadata/…/
thumb resource hashes to a different cache key between sessions, and
the backend dutifully re-downloads and re-caches the same bytes under
the new hash. On a collection that's mostly Plex (the user's 67-item
case), that means every open re-pulls 67 images over the network —
which is indistinguishable from "caching isn't working".
Normalize the cache key on both sides, keeping the original URL for
the actual authenticated fetch:
* src-tauri: cache_key_for() strips X-Plex-Token and
X-Plex-Client-Identifier before hashing. The `url` passed to
reqwest is still the original with token, so the GET authenticates
correctly; only the on-disk cache key is stable across sessions.
* frontend: same normalization in imageCacheService.cacheKeyFor()
covers the in-memory resolvedUrls map, pendingRequests dedup,
getResolvedIfCached sync hit, and preloadImages "skip if already
cached" short-circuit.
Non-Plex URLs are untouched — no '?' means no normalization work.
Qobuz's static.qobuz.com CDN URLs don't carry session params, so they
already cache correctly and this is a no-op for them.
cc
Grid-mode is already virtualized (windowedItems slice + top/bottom
spacers), so layering content-visibility:auto on top of that meant
every card off the viewport got ejected and re-painted when it came
back — the "areas ya scrolleadas se van a negro" the user flagged in
the recorded session.
Virtualization alone limits rendered cards to viewport + buffer, which
is what we actually want. content-visibility stays on .item-block
(list / expanded modes) where virtualization is disabled — that's the
mode that genuinely needs the engine-level skipping.
cc
The scroll lag in the grid view wasn't rendering cost — video analysis
(30fps extraction of a real session) showed consecutive frames stuck
identical (frame drops during scroll) plus dark placeholder cards
appearing at the bottom edge. Both symptoms trace back to the
cachedSrc action: it set opacity:0 + no src, then awaited a backend
proxy invoke per card before showing anything. On a 67-item collection
scrolling 30+ new cards into view, each new card paid a round-trip to
v2_get_cached_image before rendering — that's the placeholder flash,
and the JS/IPC load is what was eating the frame budget.
Three coordinated changes:
1. MixtapeCollectionDetailView.loadCollection() primes the image map
up front via preloadImages(). By the time the user scrolls, every
item's artwork URL has a resolved asset:// entry sitting in the
resolvedUrls map. Requests the _150 variant so the preload matches
what the grid cards actually display.
2. imageCacheService gains getResolvedIfCached() — a synchronous peek
into the in-memory map. Returns the resolved URL if hot, undefined
otherwise. file:// and asset:// short-circuit.
3. cachedSrc restructures around that sync path:
- If the cache is warm, <img src> is set synchronously on mount +
opacity stays at 1. Zero placeholder frames.
- If not warm, <img src> falls back to the raw URL immediately so
the WebView starts fetching in parallel with the backend proxy
call. If the proxy returns a disk-cached asset:// we swap it in
silently. No more opacity-dance between "loading" and "loaded".
Net effect: the first frame of each newly-visible card is already
painted with real artwork when the map was preloaded; the fallback
path only costs the raw HTTPS fetch (which the WebView already
handled in the old code too, just gated behind the proxy call).
cc
Browser-native offscreen skipping for the detail view's heavy paths.
Grid:
* .grid-card gains content-visibility: auto + contain-intrinsic-size
of 170×240. The engine can now skip layout, paint, and compositing
for cards scrolled out of the viewport — even though virtualization
already clips the windowedItems slice, the buffer rows and items
briefly off-screen during scroll were still fully rendered, which
is the cost that was showing up as stutter on large collections.
* Artwork request size bumps from _150 to _230 so the Qobuz thumbnail
matches the actual card width on the common 170–240px range. No
size change on HiDPI scaling, so crisper covers at the same-ish
wire cost (cachedSrc dedupes identical URLs across cards).
Expanded / list:
* Wrap each item-row + its optional expanded-tracks block in a new
.item-block. Apply content-visibility: auto there too with an
intrinsic hint of 100%×56 (collapsed row height). In expanded mode
the recent fix to render every visibleItem made this necessary —
50 items with all their TrackRows in the DOM was the pain point;
now the engine skips rendering for anything scrolled away,
bringing the cost back down to viewport-bounded even without
virtualizing the TrackRows themselves.
cc
Grid virtualization was using a hard-coded GRID_ROW_HEIGHT = 240.
That worked when cards were ~170px wide (the minmax floor), but
.item-grid uses auto-fill with 1fr tracks — on wider containers
each card expands past 170 and its 1:1-aspect artwork grows with it.
A card at 210px wide is ~272px tall, not 240. Over a 10-row grid
that's 320px of unaccounted content, same failure mode as the
expanded-row case: scrollbar stops short of the real bottom.
Replaces the constant with gridRowHeight derived from the actual
card width ((listContainerWidth - gaps) / gridColumns) plus a chrome
constant (title + subtitle + padding + inter-card gap). The
virtualWindow math now reads gridRowHeight each time instead of the
old fixed number, and it updates automatically when the user resizes
the window because listContainerWidth is already observed by the
existing bind:clientWidth.
Knock-on: the constant GRID_ROW_HEIGHT is gone; GRID_CARD_CHROME_H
takes its place as the part that doesn't depend on width.
cc
The detail view virtualizes items with a fixed LIST_ROW_HEIGHT of 56.
In list / grid modes the real row height is 56 by design, so the math
works. In expanded mode the row grows every time a TrackRow mounts
below the item header — a typical album-expanded row is 300–500px,
not 56. The reserved top/bottom spacers fall far short of real
content height, so the scrollbar thinks the page ends where the
spacer math says it should and the user can't reach the last row:
"scroll se regresa, casi imposible llegar al final".
Fix: in expanded mode render every visibleItem (no slicing, no
spacers). The item-row shell is lightweight (56px + a tiny meta
block); only the TrackRows below are expensive, and those were
already gated by the same virtualWindow indices we keep using for
ensureTracksLoaded — so mounts stay bounded to the viewport as
before, only the skeleton rows are now fully rendered.
list and grid modes are unchanged — they still slice windowedItems
and use the virtual spacers as before.
cc
Frontend half of the network-mount detection. PlaylistLocalTrack
(from v2_playlist_get_local_tracks_with_position) and DisplayTrack
both gain an isNetworkMount field populated from the new
local_tracks.is_network_mount column.
isTrackAvailable now checks the flag first and only falls back to
the string heuristic when the flag is missing — either because the
row predates the migration and hasn't been re-scanned yet, or because
the track was loaded on a platform where backend detection isn't
wired (macOS, Windows).
Result: the sandbox case the user flagged — GVFS-mounted NAS share
bind-mounted at /home/user/music — now properly marks tracks as
offline-unavailable when the wire is cut, where the previous
string-only heuristic would have treated them as local.
cc
Adds an is_network_mount flag to local_tracks. Populated by reading
/proc/mounts (falling back to /run/host/proc/mounts for Flatpak
sandboxes) and matching the file path's longest-prefix mount against
a list of network filesystems — nfs/nfs4, cifs/smb/smb3, fuse.sshfs,
fuse.rclone, fuse.gvfs, davfs/webdav, 9p, ceph, glusterfs, afs, afp.
Motivation: the earlier offline-awareness commit used a frontend
heuristic that matched /mnt, /media, /run/media and Windows UNC. That
catches the classic cases but misses the common sandbox case — a user
on Flathub or Snap mounts their NAS via GVFS at /run/user/1000/gvfs/…
or bind-mounts it into /home/user/music, and neither of those
triggers the frontend check. The backend sees the mount type
directly, so flagging the row at index time gives the UI ground truth.
Changes:
* new crates/qbz-library/src/mount_info.rs — is_network_path(&Path)
helper with /proc/mounts parsing, longest-prefix mount matching,
and a NETWORK_FS_PREFIXES list. Guarded with cfg(target_os =
"linux"); stubs return false on macOS / Windows (frontend keeps
its string-match heuristic there).
* LocalTrack gains is_network_mount: bool (serialized camelCase).
* local_tracks table: new is_network_mount INTEGER NOT NULL DEFAULT
0 column, both in the initial CREATE TABLE and a migration for
existing DBs.
* insert_track calls mount_info::is_network_path(&track.file_path)
and writes the flag. Qobuz cached-download inserts rely on the
column default (always 0) since those land inside the app's own
data dir.
* TRACK_COLUMNS + row_to_track pick up the new column. The two
inline LocalTrack builders in get_playlist_local_tracks and
get_playlist_local_tracks_with_position are updated the same way,
plus the playlist_position index shifts by one.
cc
Builds on the offline-awareness work: differentiates forced offline
(no_network / not_logged_in — the wire is actually cut) from manual
offline (user toggle — network is up, they just chose not to use it).
Under forced offline, two extra groups can't be reached even though
they show up as "local" in the UI:
* Plex tracks. The Plex server lives on the network; no network
means the track is as gone as a Qobuz stream.
* Network-mounted local files. If the path looks like a network
mount (Linux /mnt, /media, /run/media, /net/ autofs, or Windows
UNC \\host), the filesystem IS the network and the same rule
applies.
Under manual offline the network is still physically available, so
both groups stay playable. Qobuz is the only source blocked in that
mode — that's the user's explicit choice.
Changes:
* DisplayTrack gains isPlex (source is Plex server) and filePath
(raw audio path) fields, both in the global types and the
playlist detail's local interface.
* plexTrackToDisplay marks isPlex: true.
* localTrackToDisplay forwards the file_path.
* isTrackAvailable reads offlineStatus.reason to branch:
- Plex → unreachable under forced offline, reachable otherwise.
- Local with isNetworkPath(filePath) → same rule.
- Plain local filesystem tracks → always reachable offline.
- Qobuz → needs an offline copy as before.
* New isNetworkPath helper — heuristic match on common mount paths
plus Windows UNC. Best-effort since /proc/mounts isn't available
from the webview.
Effect: when the user yanks the cable with a playlist open, Plex
tracks and network-mounted rows join the Qobuz-without-cache ones in
getting greyed out, skipped by Play All, and auto-advanced past during
playback — same treatment, now consistent across sources.
cc
handlePlayAll and its two sibling handlers (handlePlayAllNext,
handlePlayAllLater) already filtered blacklisted tracks out of the
queue at enqueue time. They now also drop tracks that
isTrackAvailable() rejects — so a playlist queued in offline mode
contains only the tracks we can actually play, instead of padding the
queue with rows that will fail and auto-skip one after another.
The auto-skip from the earlier playbackService commit is still the
runtime safety net (e.g. a track goes missing mid-queue, or the user
toggles offline mode), but the common path — "user clicks Play on a
big playlist while offline" — now builds a clean queue up front:
* No burst of "not available offline" toasts.
* The "playing N of M" counter matches what's really scheduled.
* Playback context only includes reachable Qobuz ids, so server-
side next/prev aligns with what the client sees.
Filter logic is centralized in the existing playableTracks predicate
so all three Play-All entrypoints share the exact same rule.
cc
Offline-unavailable tracks (no local copy, we're in offline mode) now
render with the same grayscale + opacity treatment the view already
applies — isUnavailable is passed into TrackRow so the play glyph is
replaced with the unavailable icon, and the existing
.track-row-wrapper.unavailable class (which was already being
assigned but short-circuited with `pointer-events: none`) dims the
row and desaturates it.
Changes:
* Switched .track-row-wrapper.unavailable from pointer-events:none
to pointer-events:auto (matches the sister .removed-from-qobuz
behavior) so right-click still opens a context menu. Opacity
bumped from 0.4 to 0.5 for consistency with removed rows.
* Populated menuActions for the offline-unavailable case — go to
album, go to artist, show info, and remove from playlist when
it's the user's own playlist. Intentionally omits play*, add to
playlist, download, and (per user request) Find Replacement, since
offline is a transient state and none of those actions would help.
* TrackRow kept simple: the red CircleAlert icon it already rendered
gets desaturated by the parent's grayscale filter, so the offline
state reads as muted without needing a dedicated icon variant.
With the earlier auto-skip fix, playback now also just moves past
these rows instead of getting stuck on them.
cc
When the backend rejects a track with TrackNotAvailableOffline (manual
offline mode, no cached copy), playbackService used to surface a toast
and then stop the queue dead. The user had to manually click Next to
continue — in a 20-track playlist with three cached tracks, they'd hit
that wall 17 times.
Now that error path pulls the next track via the same mechanism the
"removed from Qobuz" case already used (Next in the queue, respect the
track's resolved source / isLocal / sampling rate), so the queue rolls
through missing tracks automatically and lands on the next playable
one. If there's nothing left, isPlaying is still cleared so the UI
reflects the stopped state.
Refactor: the auto-skip block was duplicated between the Qobuz-
unavailable branch and would have been duplicated again for the
offline branch, so it's extracted into a single autoSkipToNext helper.
Part of the "offline playlists are available" story.
cc
Two bugs were making the playlists section disappear when the user was
offline, even though the SWR cache already had every regular playlist
from the previous online session:
1. The whole section was wrapped in {#if !isOffline} — an all-or-
nothing kill switch that ignored the fact that individual
features (create, import, drag) already had their own per-
control isOffline gates inside.
2. loadUserPlaylists in offline mode overwrote userPlaylists with
only the pending-playlists list, wiping whatever the SWR cache
restored on mount. So even if the gate were lifted, the list
would be empty the moment the offline effect fired.
Changes:
* Render the playlists section unconditionally. Per-control
network-gated behavior (import button, drag-to-reorder, folder
creation) still honors isOffline individually.
* Offline loadUserPlaylists now keeps the cached regular playlists
(id >= 0) in place and layers pending playlists on top, merging
their local-track counts into the existing map instead of
replacing it.
Opening a cached playlist in offline mode surfaces the tracks the
user had; playback falls back per track to offline cache / local
library / Plex exactly as it already does when partial content is
unavailable.
cc
Plex track artwork in plex_cache_tracks.artwork_path is stored as a
library-relative URI (e.g. /library/metadata/123/thumb/…) which the
Plex server resolves when combined with the configured base URL and
an X-Plex-Token query param. The previous commit was treating it like
a local file path and wrapping it in asset://localhost/, so browsers
couldn't fetch it and the cover rendered broken.
Adds a buildPlexArtworkUrl helper mirroring LocalLibraryView's
implementation — reads qbz-plex-poc-base-url / qbz-plex-poc-token from
userStorage, appends X-Plex-Token, leaves already-absolute URLs
untouched. Used by plexTrackToDisplay when populating albumArt.
cc
Final piece of the Plex-in-playlist plumbing. PlaylistDetailView now
loads the (ratingKey, position) pairs from playlist_plex_tracks and
hydrates metadata from plex_cache_tracks, then interleaves the results
with Qobuz and local tracks by playlist_position so the ordering the
user sees matches what got stored when they added each track.
Backend:
* plex_cache_get_tracks_by_keys(rating_keys) in plex/mod.rs returns
PlexTrack metadata for a set of ratingKey strings, skipping keys
the cache doesn't know about.
* v2_plex_cache_get_tracks_by_keys Tauri command exposing it,
registered in lib.rs.
Frontend:
* New PlaylistPlexTrack interface + plexTracks state alongside the
existing localTracks pipeline.
* loadPlexTracks() does the two-step hydrate (positions, then meta).
Called in parallel with loadLocalTracks and loadPlaylist.
* plexTrackToDisplay() converts to DisplayTrack. The frontend id is
derived from the ratingKey via a djb2 hash offset into a
PLEX_DISPLAY_ID_OFFSET-anchored negative range — stable and never
collides with local (small negative) or Qobuz (positive) ids.
* displayTracks interleaves local + plex + Qobuz tracks by position.
Fast path unchanged when neither source has content.
* hasLocalTracks, totalTrackCount, totalDuration, and the sidebar's
hasLocalContent indicator all pick up Plex rows automatically
because the backend's get_all_playlist_local_track_counts already
merged the plex count in an earlier commit.
Missing plex cache entries (purged, never hydrated) are silently
dropped instead of rendering placeholder rows — the playlist just
shows fewer Plex tracks until the next Plex cache refresh.
Fixes the bug: Plex tracks can now be added from LocalLibrary and
they show up in the playlist detail view, completely decoupled from
the Qobuz playlist API.
cc
Frontend half of the fix. LocalLibraryView no longer gates Plex
tracks out of "Add to playlist" — the whole add pipeline now routes
by source:
* source === 'plex' → v2_playlist_add_plex_track(playlistId,
ratingKey, position). ratingKey comes from track.file_path, which
is where mapPlexTrack stashes it at load time.
* source in {user, qobuz_purchase, qobuz_download} →
v2_playlist_add_local_track(playlistId, localTrackId, position)
(unchanged).
* source === qobuz (Qobuz catalog track) →
v2_add_tracks_to_playlist (unchanged).
Changes:
* uiStore: new playlistModalPlexRatingKeys slot + getter + passed
through openPlaylistModal (4th arg) and closePlaylistModal.
* PlaylistModal: new plexRatingKeys prop, isPlexTracks derived flag,
and branches in handleAddToPlaylist / handleDirectAddToPlaylist /
handleCreateAndAdd that emit v2_playlist_add_plex_track. Duplicate
detection (which only covers Qobuz) is skipped for Plex adds.
tracks_count update for newly-created playlists now treats Plex
like local — 0, since the Qobuz side doesn't own those tracks.
* LocalLibraryView: adds onTrackAddPlexToPlaylist /
onBulkAddPlexToPlaylist callbacks. handleBulkAddToPlaylist and
handleAlbumBulkAddToPlaylist split the selection by source and
emit the right callback per subset. The per-row plex gate at
line 3509 is replaced with source-aware routing.
* +page.svelte: new openAddPlexToPlaylist helper + wired the two
new callbacks into the LocalLibraryView render.
No confusion with Qobuz tracks: Plex rating keys never touch the
Qobuz playlist API, and local-track ids never reach the Plex
command. The two namespaces stay physically separate at every layer.
cc
Wires the qbz-library methods added in the previous commit to Tauri
invoke handlers:
* v2_playlist_add_plex_track(playlistId, ratingKey, position)
* v2_playlist_remove_plex_track(playlistId, ratingKey)
* v2_playlist_get_plex_tracks_with_position(playlistId)
All three lock LibraryState::db, mirror the existing local-track
commands, and go nowhere near the Qobuz API surface — no chance of a
Plex ratingKey being forwarded to /playlist/addTracks by mistake.
Registered in lib.rs invoke_handler next to v2_playlist_add_local_track.
cc
First half of the Plex-tracks-in-playlists fix. Adds a dedicated
playlist_plex_tracks table alongside the existing playlist_local_tracks
so Plex content (TEXT rating key, owned by a remote server) doesn't
collide with the i64 filesystem id used by local_tracks. No foreign
key to plex_cache_tracks — the cache can be purged independently and
the user's intent should survive.
Database methods added:
* add_plex_track_to_playlist(playlist_id, rating_key, position)
* remove_plex_track_from_playlist(playlist_id, rating_key)
* get_playlist_plex_tracks_with_position(playlist_id) ->
Vec<(rating_key, position)>
* get_playlist_plex_track_count(playlist_id) -> u32
get_all_playlist_local_track_counts now merges plex counts into the
same HashMap so the sidebar's hasLocalContent signal stays a single
number and covers Plex-populated playlists without a second round
trip from the frontend.
cc
Custom playlist covers (set from PlaylistDetailView) were visible in
the detail header but not in the sidebar, where the 2×2 collage always
won. The sidebar now shows the custom cover as a single 20×20 tile and
only falls back to the collage / ListMusic icon when no custom cover is
set.
To avoid decoding a multi-MB JPEG for a 20×20 slot, local-file covers
pipe through the existing v2_library_get_thumbnail command — generates
a 500×500 JPEG with the standard on-disk cache so the webview only ever
hands a small resized file to the <img>. Remote URL covers skip the
thumb pipeline and render directly.
Changes:
* PlaylistSettings TS interface gains custom_artwork_path.
* resolveCustomCover(playlistId) returns the thumb URL when available,
the asset://localhost-mapped original as fallback while the thumb
is generating.
* ensureCustomCoverThumbs(settings) fans out to generate + cache the
thumb URL per playlist. Called from both the SWR cache-hydrate path
and the fresh backend fetch.
* Both playlist render sites (folder-playlist + root-playlist) check
the custom cover first before the collage / ListMusic fallback.
cc
Two things:
1. home.recentAlbums and home.continueTracks (the labels shown in the
"Customize Home" modal) used to read "Recently Played" / "Continue
Listening". They now read "Recently Played Albums" / "Recently
Played Tracks" so the modal labels match the section titles that
were renamed earlier.
2. Default limit for continueTracks (now Recently Played Tracks)
changes from 10 → 24. The new grid carousel renders 4×3 = 12
visible cards and can scroll to one more page; 24 fills the two
pages exactly.
i18n updated across all 5 locales.
cc
Replaces the vertical TrackRow list in the Home / For You tabs with a
horizontally-paged grid: 3 rows visible, 4 columns wide on desktop
(responsive down to 3 / 2 / 1 at 1100 / 820 / 540 px breakpoints), up
to 24 tracks total. Scroll arrows advance by a single column at a
time, scroll snap keeps columns aligned, drag-to-scroll is
implicit via the native overflow.
New components:
* TrackGridCard.svelte — per-track card: 48×48 cover with a
play/pause overlay on hover, stacked title / clickable album
name, and a TrackMenu (…) button that appears on hover /
active-track states. Right-click opens the same context menu.
Reuses TrackMenu / favoritesStore so wiring matches the existing
TrackRow behavior.
* TrackGridCarousel.svelte — section container: title, two nav
buttons, and a grid-auto-flow:column viewport that exposes
--col-width / --col-gap / --rows CSS vars tuned by a
ResizeObserver on the viewport. 3 rows is the default; callers
could override.
Integrated into both ForYouTab.svelte and HomeView.svelte — same
key (home.continueListening) drives both surfaces, so the new
presentation is consistent across the Discover tabs.
cc
Renames two home-tab / for-you-tab section titles so their contents
aren't ambiguous once they sit next to each other on the same screen:
* home.continueListening: "Continue Listening" → "Recently Played Tracks"
* home.recentlyPlayed: "Recently Played" → "Recently Played Albums"
Both tabs (Home and For You) read the same keys, so a single change
covers both surfaces. Translated across all 5 locales (en/es/de/fr/pt).
cc
The sidebar already respected the titlebar-owned flag for Discover,
Favorites, Library, and Purchases but not for My QBZ — toggling
"Show My QBZ in titlebar" in Settings duplicated the entry in both
places. Add a myQbzInTitlebar prop plumbed from +page.svelte
(tbNavMyQbz && showTitleBar, same pattern as the others) and gate the
sidebar block on it.
cc
The "PLAYLISTS" section label no longer carries an icon — it returns
to a plain .section-header div. With padding: 0 8px, the text now
starts at x=8px, which matches where the icon containers of every row
below it begin, so the header visually sits on the same vertical line
as the sidebar's icon column.
Cleanup:
* Removed the PlaylistsGlyph import.
* Removed the .section-header-with-icon CSS rule, now orphan
(Library stopped using it when that section got flattened).
The PlaylistsGlyph component and the source SVG under static/ stay
for future use.
cc
Small tightening pass — 22×22 felt bulky in practice. 20×20 keeps the
Cider-style alignment guarantee (all labels share the same x) while
giving the sidebar a slightly denser look.
Touches NavigationItem's iconSize default, the 5 custom-sized
icon-containers in Sidebar (my-qbz, favorites, folder header, popover
header, context menu), and the two PlaylistCoverCollage size={20}
call sites.
cc
Cider-style alignment: the glyph inside the container can be any size
(most lucide nav icons still render at 14×14), but the container slot
is always 22×22 so labels across every sidebar entry land at the same
horizontal position regardless of whether the row shows a lucide icon,
a folder glyph, a collage, or the favorites/my-qbz mask.
Changes:
* NavigationItem iconSize default 14 → 22 (only affects the
container; the caller-supplied icon snippet is unchanged).
* Playlist rows drop the explicit iconSize prop (default now matches
the collage already at 22×22).
* .my-qbz-parent .icon-container, .favorites-nav-item .icon-container
bumped from 14×14 to 22×22.
* Folder header, folder popover header, and "move to folder" context
menu items: wrap the FolderGlyph in an .icon-container div and
size it 22×22. .folder-header also gains height:32px, gap:8px and
padding:0 8px to match NavigationItem.
cc
First pass pushed sidebar labels all the way to --text-primary (same
brightness as track titles in detail views), which read too loud in
actual use. Matches the Settings view's .setting-label tone
(--text-secondary) — brighter than the original --text-muted baseline
but calmer than --text-primary.
Also fixes the Favorites / Library row that was still running on its
old hand-rolled .favorites-nav-item styles (text-muted, no icon
accent): now matches the rest of the sidebar with text-secondary on
the row and accent-primary on the icon container. The
.favorites-nav-item.active override that bumped back up to
text-primary was removed too, since the background tint alone is
enough to mark the active state.
Surfaces touched:
* NavigationItem: .nav-item + .label → --text-secondary
* .favorites-nav-item: same + icon-container gets accent
* .my-qbz-parent: --text-secondary
* .folder-name: --text-secondary
* .folder-popover-header > span: --text-secondary
* .section-header-with-icon > span: --text-secondary
cc
Removes the collapsible Local Library section — header button with
chevron + Browse child row — in favor of a single NavigationItem that
goes straight to the library view. Label is "Local Library"
(library.title), icon is LibraryBig at 14×14 (inherits accent via
NavigationItem's icon-container like every other nav entry).
Also removes the now-unused localLibraryCollapsed state, its entry in
the SidebarCollapseState interface, and the HardDrive icon import
(was only used by the removed Browse row). Persisted collapse state
from previous sessions is read with ?? false so old keys are ignored
gracefully without touching the stored blob.
Side effect: the earlier Library section-header-with-icon tint issue
is moot — the section header that needed custom styling no longer
exists.
cc
Addresses three visual gaps in the sidebar after the earlier brightening
pass:
1. My QBZ parent — icon was inheriting the row's text-muted color
because the custom .icon-container had no color rule of its own. Now
the icon container sets accent-primary and the parent row's label
uses text-primary, matching NavigationItem.
2. Folder rows — the folder glyphs were still muted because .folder-
header / .collapsed-folder-btn / .folder-popover-header all carried
text-muted as their base color, and FolderGlyph paints via
currentColor. Switched each surface's base color to accent-primary
and added explicit text-primary overrides on the actual label spans
(.folder-name, the popover header span) so the labels stay bright.
3. Section headers — PLAYLISTS had no icon at all and Library only had
a chevron. Added glyphs via a new .section-header-with-icon class
that runs accent-primary at the container level (so the SVG picks
up the tint) with a nested span override for the uppercase label.
PLAYLISTS uses a new PlaylistsGlyph.svelte (user-supplied
list-music-player SVG, checked into static/). Library uses
lucide-svelte LibraryBig for now — swap to a custom SVG later if
desired.
cc
Swaps the outline lucide Folder / FolderPlus icons throughout the
sidebar for the user-supplied solid SVGs, with a state-aware variant
on the folder header so expanded folders actually *look* open.
New component: icons/FolderGlyph.svelte — a single file with three
variants (closed / open / new), fill="currentColor" so it picks up the
surrounding --accent-primary color from the nav item.
Placements:
* Folder header (collapsible tree node): open variant when expanded,
closed when not. Reinforces the chevron rotation with a second
visual cue.
* Collapsed-sidebar folder button: closed (there's no expand state
when the whole sidebar is narrow).
* "Move to folder" context-menu entries: closed (list items).
* Folder popover header (opened from the collapsed-sidebar button):
open (the popover IS showing the folder's contents).
* "New Folder" menu item: the new variant (with the + indicator
baked into the glyph).
Source SVGs are checked into static/ for traceability.
cc
Adds Settings > Appearance > "Show playlist cover collages in sidebar"
(on by default, persisted to localStorage as
qbz-sidebar-playlist-collage).
When off, playlist rows in the sidebar fall back to the original
ListMusic icon at its natural 14×14 size — same row alignment as
every other nav item. When on, the 2×2 collage renders as before.
Toggle is reactive via a new subscribePlaylistCollage listener in
sidebarStore, so flipping it applies immediately without a refresh.
Intended for low-end machines where the four extra <img> tags per
visible playlist row (even at _50.jpg, even gated by virtual scroll
and loading="lazy") are still measurable overhead.
i18n: added keys to all 5 locales (en/es/de/fr/pt).
cc
The collage renders at 22x22 total (each tile ~11x11), so Qobuz's
_150.jpg variant (that comes back in images150/300) is roughly 13×
oversampled and ~4× heavier on the wire than needed. Rewrites matching
Qobuz cover URLs to the _50.jpg variant — ~2 KB per tile instead of
~8 KB. A playlist row with 4 tiles drops from ~32 KB to ~8 KB.
Regex only touches URLs ending in `_150.jpg` / `_300.jpg` / `_600.jpg`
(optionally followed by a query string), so non-Qobuz URLs (local
library covers, data URIs) pass through untouched.
cc
Replaces the ListMusic icon on user-playlist rows with a 22x22
2x2 collage of the first four track covers when available, matching
the visual pattern used by Cider and other modern music clients.
Data source: images150 / images300 already come back from
v2_get_user_playlists (qbz-models::Playlist.images150/300). Zero
extra backend calls; the sidebar Playlist interface just gains the
two fields that were being stripped by the narrower TS type.
Resource cost: <img loading="lazy"> + the existing virtual scroll in
the sidebar ensures covers only fetch for playlist rows currently
visible. Playlists nested in collapsed folders are excluded from
visiblePlaylistItems, so their covers never load until the folder
is expanded.
Fallback: if a playlist has no cover images (edge case — playlist
with no tracks, or API omits them), the original ListMusic icon
renders inside the same 22x22 slot to keep row alignment stable.
Components:
- PlaylistCoverCollage.svelte (new): 22x22 grid / single-image /
empty-passthrough based on image count.
- NavigationItem.svelte: adds iconSize prop (default 14) so
playlist rows can expand to 22x22 without affecting the other
nav entries.
- Sidebar.svelte: integrates the collage into both folder-playlist
and root-playlist render paths.
cc
Makes sidebar entries more legible at a glance — item labels now use
--text-primary (was --text-muted, inherited from the button) and the
icon container uses --accent-primary. Matches the visual hierarchy
common in modern music clients (label = content, icon = branded
affordance).
Active state keeps its background highlight but no longer overrides
the label color, since both active and inactive labels are now at the
brightest tier.
cc
Last of the four seekbar surfaces. Previously only NowPlayingBar had
the fix; the immersive-mode compact controls kept showing the old snap
back to pre-seek position during the decoder reinit window.
Adds pendingSeekTime, the 2s lands-near-target detection effect, the
8s safety timeout, and a spinner thumb that renders only while pending
(the immersive design has no thumb by default, so we inject one via
{#if isSeekingPending}).
Part of #335.
cc
Same treatment as NowPlayingBar and ExpandedPlayer — the miniplayer
already deferred onSeek until mouseup, but had no pendingSeekTime, so
the thumb still snapped back to pre-seek position during the 1–2s
decoder reinit window.
Adds pendingSeekTime, a 2s-window effect to clear it once currentTime
lands, an 8s safety timeout, and a spinning-ring variant of the thumb
while the seek is pending. The thumb was display:none in the
miniplayer's minimalist style; it now renders when .seeking is set so
the user gets feedback.
Part of #335.
cc
Brings the large-player seekbar up to parity with NowPlayingBar so long
seeks no longer show the thumb snapping back to the pre-seek position
during the 1–2s decoder reinit window.
Changes:
* Defer onSeek until mouseup (was firing on every mousemove, which
flooded the backend with seek commands and made the visual feedback
noisy).
* Add dragPreviewTime for in-drag thumb position.
* Add pendingSeekTime — thumb stays pinned to target after mouseup
until currentTime lands within 2s of it.
* Spinner on the thumb while pendingSeekTime is active, with an 8s
safety timeout to clear if the seek never lands.
* Time display reads from effectiveTime so the user's intended
position shows through the wait.
Part of #335.
cc
After the user releases the seekbar the backend still needs 1–2s to
reinit the decoder, run the format seek and restart the audio engine
(especially for long jumps on FLAC Hi-Res). The thumb used to snap back
to the pre-seek position during that window, giving the impression the
seek was rejected, then jump to the clicked position when audio
resumed.
Now the thumb stays at the clicked time (pendingSeekTime) and renders
as a spinning ring until currentTime lands within 2s of the target.
Includes an 8s safety timeout so the UI can't be stuck spinning if the
seek never lands (track ended, device lost, etc.).
Part of #335.
cc
Adds InMemorySource — a Symphonia FormatReader+Decoder backed by an
in-memory Cursor — and wires it into the cached branch of the Seek
handler so FLAC Hi-Res and MP3 seeks on LRU-cached / local-library /
offline-cached tracks jump via seek table instead of decoding every
sample from byte 0. Matches the streaming path's behavior, eliminates
the CPU spike during long seeks that was producing audible stuttering
after the streaming seek fix landed.
Falls back to the previous decode_with_fallback + skip_duration path
when Symphonia can't probe the format (rodio-only MP4/AAC container)
or when the native seek itself fails, so nothing regresses for those
formats.
Part of #335.
cc
Replaces skip_duration's decode-every-sample-from-zero loop with
FormatReader::seek(SeekMode::Accurate, SeekTo::Time) on a fresh
IncrementalStreamingSource. For FLAC this jumps via the seek table to
the nearest seek point, then decodes forward to the exact sample —
instead of grinding through millions of samples under the buffered
source's mutex (the cause of post-seek stuttering).
Added IncrementalStreamingSource::seek_to(Duration) which also resets
the decoder, clears the sample queue, and rearms the finished flag so
the source is cleanly re-primed for the iterator.
Non-streaming seeks (cache, offline-cache, local library) still use
skip_duration — they hit in-memory bytes with no lock contention, so
the linear decode is effectively free and there's no reason to change
the path.
Part of #335.
cc
Communicates the seek ceiling that the drag clamp already enforces.
When the mouse hovers the portion of the seekbar past
bufferProgress × 0.9, the cursor switches to not-allowed so the user
gets immediate feedback that aiming there won't land.
Only active while a CMAF stream is still downloading
(bufferProgress < 1). Fully cached, offline, and local-library playback
keep the normal pointer cursor across the full bar.
Part of #335.
cc
While a CMAF stream is still downloading, the backend rejects seeks
past the buffered watermark. This clamps the drag visually to the same
ceiling (bufferProgress × 0.90) so the user can't aim at a position the
backend would bounce. The 0.90 factor matches the safety margin on the
Rust side so frontend and backend agree on the cutoff.
Fully cached, offline, and local-library tracks (bufferProgress === null)
are unaffected and keep full-range seek.
Part of #335.
cc
Relaxes the seek gate added in 2dc1ee9e so seeks that fall inside the
already-downloaded portion of a streaming track succeed instead of being
rejected wholesale. The cutoff is approximated from download fraction ×
track duration with a 10% safety margin (0.90) to cover FLAC/VBR byte
density variance; exact for CBR.
Seeks past the watermark still reject cleanly with a warning log — they
would block the audio thread since skip_duration reads samples
sequentially. Cache, offline-cache, and local-library seeks are
untouched (they don't hit the streaming branch).
Part of #335.
cc
Tightens the streaming-seek handler to the simpler contract signed off
by the user: seek works after a streaming track finishes downloading
(buffered_source.is_complete()), but is rejected cleanly with a warning
log while the download is still in progress. Avoids skip_duration
blocking the audio thread on past-watermark seeks without adding
watermark-tracking complexity.
Cache, offline-cache, and local-library seeks are unaffected — they
reach this handler with current_audio_data Some and skip the streaming
branch entirely.
Fixes part of #335.
cc
The audio thread's Seek handler bailed on `current_audio_data.is_none()`
with a warning and returned. That field is intentionally None during
streaming playback — the bytes live inside current_streaming_source's
Arc<BufferedMediaSource>. Effect: every seek on a streaming track with
the CMAF download still in progress was silently dropped; the UI sent
the command, the player ignored it, and the seekbar snapped back to
whatever position the playback timer had reached (issue #335).
Branch the handler on the live source kind. Streaming: build a fresh
IncrementalStreamingSource from the shared buffered_source (new reader
at byte 0, same downloaded bytes — no re-download), then skip_duration
to the target. Non-streaming path unchanged.
Within-watermark seeks now work. Past-watermark seeks currently block
the audio thread while the decoder waits for more bytes; the clamp to
prevent that lands in the next commit.
cc
Minimal distros (the reporter's Raspberry Pi OS case in issue #331) ship
a trimmed /etc/asound.conf that defines only `!default` and doesn't pull
in the broader alias set most desktop distros provide. A card like the
HifiBerry Digi2 Pro — S/PDIF-only, so its selectable PCM is
`iec958:CARD=sndrpihifiberry,DEV=0` — then fails to open even though
the card is present and usable via the raw `hw:` device.
When CPAL's primary lookup by id returns None AND /proc/asound confirms
the card exists, rebuild the id as `hw:CARD=<name>,DEV=<n>` and retry
the lookup. The raw `hw:` PCM is exposed by the kernel driver for every
registered card, so it's a safe last resort — same card, same channel
routing, just skipping whichever userspace alias layer was missing.
Log both the alias failure and the fallback at warn/info so users can
see what happened without needing to diagnose ALSA config by hand.
Original error path is preserved when no fallback is possible (unknown
id shape, card not in /proc/asound, or hw: equally unavailable).
cc
Device enumeration produces four "CARD=" alias shapes (front:, sysdefault:,
iec958:, hdmi:) but the extractor only handled the first two. When a user
selected an S/PDIF or HDMI device id, the downstream proc_found check
always returned false, so the device-not-found error message dropped to
the generic "disconnected, renamed, or handled by another app" wording
even when /proc/asound showed the card present.
Part of issue #331 — reporter's HifiBerry Digi2 Pro is S/PDIF-only, so
its id is iec958:CARD=sndrpihifiberry,DEV=0, and the missing prefix match
obscured the real cause of the open failure.
This commit is a pure parsing extension; behavior changes only in the
error message accuracy. The hw:/plughw: fallback attempt lands in the
next commit.
cc
Issue #333. handleShuffle was calling handlePlayAll() which hardcoded
playableTracks[0] as the track to start playing. After v2_set_shuffle(true)
the queue was correctly shuffled, but the UI always landed on track 0 —
defeating the point of playlist-level shuffle.
Match the pattern ArtistDetailView / LabelView / LocalLibraryView and
+page.svelte::handleShuffleAlbum already use: pick a random index from
the blacklist-filtered playable set, pass it to handlePlayAll, and let the
existing qbz-player/queue.rs `set_queue` semantics move that index to
shuffle_order[0] so the remaining tracks still cover the full playlist
in random order.
Not touching the Rust-side "CRITICAL FIX" block in queue.set_queue even
though the user suggested removing it — that block is load-bearing for
the other callers that already pass a random index and expect it to
become the shuffle head. Removing it would regress those views.
handlePlayAll now takes an optional `startIndex = 0` so the Play-All
button (unchanged behavior — plays from start) and the Shuffle button
(random start) share the same implementation. Context position is
clamped against the Qobuz-only subset so a random pick landing on a
local track doesn't point past its end.
cc
Drop the `!isLocal` gate on the cast branch so castTrack is invoked for
every source when a cast device is connected. castTrack now knows how to
dispatch to the three source-specific backend commands; the guard was
the only thing keeping non-Qobuz tracks from ever reaching the cast pipeline.
Fixes issue #332: user connects to a DLNA renderer, plays a local file,
expects audio on the renderer, gets silence (audio routes to app's local
ALSA output instead). Same shape as Foobar2000's local-over-DLNA workflow
the reporter compared against.
cc
Previously castTrack hardcoded two calls (v2_cast_play_track for
Chromecast, v2_dlna_play_track for DLNA) which both assume a Qobuz track
id. Extend the signature with an optional source parameter (default
'qobuz' so existing callers keep working) and route to the matching
backend command per protocol × source.
Plex branch pulls baseUrl + token from userStorage the same way
playbackService does for local Plex playback and passes ratingKey as a
string to the backend. Local branch uses trackId as-is (library row id).
Follow-up commit removes the `!isLocal` guard in playbackService so
non-Qobuz tracks actually take this branch.
cc
Plex has the same cast routing problem as local library: the Qobuz-only
play_track commands can't resolve Plex ratingKeys. Add dedicated commands
that fetch bytes through plex_resolve_track_media (existing), proxy them
through the cast media server, and load the URL on the renderer.
Proxying through the media server (rather than giving the renderer a
direct Plex URL) keeps the user's Plex token in-process. Renderers would
need the token to fetch from Plex directly and that's not a surface we
want to expose.
Content-type fallback to audio/mpeg when Plex omits it (transcode path),
with a plex_key_to_u64 namespaced hash (high bit set) so Plex ratingKeys
can't collide with Qobuz/library ids in the MediaServer keyspace.
cc
Companion to v2_dlna_play_local_track. Same bug, same fix applied to the
Chromecast protocol branch: reads library row + file, infers content_type,
registers bytes with the shared media server, loads the URL on the
Chromecast receiver. Reuses the local_track_content_type helper.
User flagged this on the #332 thread — same Qobuz-only assumption in the
Chromecast play command as in the DLNA one.
cc
Mirrors v2_dlna_play_track but for library row ids. Reads the file from
disk, infers content_type from AudioFormat (with extension fallback for
Unknown), registers bytes with the DLNA media server, sends AVTransport
URI + Play to the renderer.
Without this command the cast path had no way to send local audio to a
DLNA device — v2_dlna_play_track resolves the id as a Qobuz track and
fails (404 in offline sessions, wrong-song match in online sessions where
the library row id happens to collide with a real Qobuz track id). Issue
#332 reported the symptom as "DLNA not operational"; logs showed the
renderer connected successfully but never received a SOAP SetAVTransportURI.
Frontend wiring in the cast dispatch + playbackService guard removal lands
in follow-up commits.
cc
Mixtapes & Collections — a second container family alongside Qobuz
playlists. Private to the install, cross-source (Qobuz + LocalLibrary +
Plex), with its own detail view, Add-to picker, bulk ops, per-collection
view prefs, and a Discography Builder for one-click Artist Collections.
Backend:
- New tables mixtape_collections + mixtape_collection_items with
CASCADE + additive migrations.
- New v2_* command surface: CRUD, enqueue_collection,
enqueue_collection_item, skip_to_next_item / previous_item for
album-boundary navigation, mixtape_item_exists for duplicate checks,
custom cover upload / remove.
- Plex branch in resolve_local_album so plex: album keys route to the
plex cache instead of local_tracks.
Frontend:
- Sidebar My QBZ collapsible nav, title bar entry, index + detail views
with virtualization / sticky unified toolbars / size-aware Qobuz
artwork / image cache. Per-collection view prefs (viewMode / sort /
filter) persisted in per-user localStorage.
- Index views get search + sort + kind filter + list-or-grid toggle.
- Add-to-Mixtape wired across ArtistDetail / Search / Label /
PlaylistDetail / LocalLibrary; multi-select + bulk ops for albums.
- Queue / playback fixes bundled: per-album Play for all three sources,
session source round-trip for local/Plex auto-advance, NowPlayingBar
artwork resolver for Plex / local paths, clear-queue includeCurrent
flag, natural-end queue cleanup.
Release notes in docs/release-1.2.8/TAG_DETAILS.md.
cc
Release notes for 1.2.8 explaining Mixtape vs Collection vs Playlist in
user-facing terms: playlists are Qobuz-hosted track lists, Mixtapes are
private ordered cross-source listening sessions, Collections are
private themed album shelves. Covers the Discography Builder, the
Add to Mixtape picker kind rules, custom covers, duplicate-handling
dialog, and the under-the-hood notes for upgraders (new tables, new
v2_* command surface, Plex queue resolver branch).
cc
Consolidates the +page.svelte side of several independent fixes/features
whose non-+page pieces landed in the preceding commits.
- handleMixtapeItemAction: was calling v2_set_queue with { trackIds }
but the command expects { tracks, startIndex } — Tauri rejected the
call so per-album Play from a Collection did nothing. Now routes
Qobuz items through the existing fast path (with blacklist filter +
artwork fallbacks) and local/Plex items through the new
v2_enqueue_collection_item backend command, then resolves the queue
via playQueueIndex(0) + playQueueTrack(trk) so audio actually starts
(v2_play_queue_index only moves the cursor; it doesn't tell the
player to load bytes).
- Session save (saveSessionBeforeClose): includes source on each
PersistedQueueTrack. Without it, a LocalLibrary queue came back as
is_local=true / source=undefined and the auto-advance path routed
local row-ids to v2_play_track (Qobuz).
- Session restore: map session.queue_tracks with a source fallback
(trk.source ?? (trk.is_local ? 'local' : undefined)) so older saved
sessions (pre-source-field) still route correctly. setCurrentTrack
default for source is now 'local' when is_local is true, not
hardcoded 'qobuz'.
- playQueueTrack: artwork goes through resolveQueueTrackArtwork so a
raw Plex /library/... thumb path or a bare local filesystem path
becomes a valid src for NowPlayingBar instead of a 404.
- Title bar: isMyQbzInTitlebar state + showMyQbz prop passed to
TitleBarNav so the new My QBZ entry can be toggled from Settings.
cc