Previously the prop was named isQobuzConnectConnected, which suggested
transport-up semantics, but after 23658b05 it carries the user-intent
toggle state (true during Connecting and Reconnecting too). Rename to
match what the value actually means, removing the chance of a future
reader misreading the prop as a "safe to send commands" gate.
No behavior change.
Refs: #358
cc
When the reconnect loop is stuck waiting for a SESSION_STATE that never
comes (the actual symptom of issue #358), the runtime is alive but
`transport_connected` is false. The UI used to read its on/off toggle
from `transport_connected`, so the badge appeared OFF while the backend
loop kept retrying. Worse: clicking the toggle in that state called
`connect()`, which rejected with "QConnect service is already running".
The user had no way to disable QConnect from the UI — only to "turn it
on more", which failed.
Backend (`qconnect_service.rs`):
- Add `QconnectLifecycleState` enum (Off / Connecting / Connected /
Reconnecting / Exhausted) and surface it on
`QconnectConnectionStatus::state`. Driven from the spawned transport
event loop via a new `update_lifecycle_state_if_running` helper which
refuses to overwrite terminal states (Off / Exhausted) with late
Reconnecting / Connected events from the broadcast queue.
- Make `connect()` idempotent: if a runtime already exists (alive but
reconnecting), return current status instead of erroring. The toggle
is the single user-visible control; the previous "already running"
error was unactionable from the UI.
- Auto-stop on `MaxReconnectAttemptsExceeded`: take the runtime out,
set state=Exhausted, set last_error to the cloud reason, and emit a
`qconnect:status_changed` event so the frontend reflects the new
state without waiting for its 5 s poll. The transport loop has
already exited at this point, so a fresh user-initiated connect()
cleanly starts a new runtime.
- `disconnect()` now forces lifecycle to Off unconditionally and tears
down the runtime (already worked, the transport's `shutdown_tx`
watch breaks `handle_reconnect_delay` mid-sleep), satisfying the
"disable QConnect must always succeed" requirement.
Frontend (`qconnectRuntime.ts`, `+page.svelte`):
- Add `QconnectLifecycleState` mirror type and `isQconnectToggleOn()`
helper.
- Split state in the page: `isQobuzConnectConnected` keeps its old
semantics (`transport_connected`) and gates send-command paths
(volume reports, position reports) so we don't fire commands while
the WS is down. New `isQobuzConnectToggleOn` reflects the
user-facing on/off — true even during Reconnecting/Connecting — and
is what the badge component and the toggle handler now read.
- Listen for the new `qconnect:status_changed` event so the toggle
reflects auto-stop (Exhausted) and other transitions immediately
instead of after the 5 s status poll.
Refs #358
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
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
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
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
Handlers + callback wiring in the root view layer:
DiscographyBuilder
- onOpenAlbum → handleAlbumClick (Qobuz) or selectLocalAlbum (local)
so clicking a candidate album navigates to its detail.
Mixtape / Collection detail
- onOpenItem for album / track / playlist rows.
- onOpenArtist runs handleOpenArtistByName: v2_search_artists(name)
and navigates to the top match — collection items only store an
artist name, not an id.
- onPlayItem / onPlayItemNext / onAddItemToQueueLater wired to a
new handleMixtapeItemAction that fetches the item's tracks
(Qobuz album path) and sets / appends the queue. Non-Qobuz
item types toast a "not available yet" message.
- Bulk variants (onBulkPlayNext / onBulkPlayLater /
onBulkAddToPlaylist) iterate through selected items.
- onPlayTrackFromItem (expanded-view TrackRow Play): fetches the
parent album and jumps to the picked track's index.
- onPlayTrackNext / onPlayTrackLater: fetch via v2_get_track and
call v2_add_tracks_to_queue_next / v2_add_tracks_to_queue for a
single Qobuz track.
Adds selectLocalAlbum + MixtapeCollectionItem to the existing
imports.
cc
- back-to-top-global button z-index 50 → 200 so no view's sticky
footer (Collection detail BulkActionBar, DiscographyBuilder footer,
etc.) can park on top of it.
- AddToMixtapeModal now receives items={} (array) instead of
item={} (single) after the store + modal signature moved to the
bulk-capable shape.
cc
Two related UX fixes in handleClearQueue + the setOnTrackEnded branch:
- handleClearQueue now passes includeCurrent=!isPlaying. When the
user hits Clear while nothing is actively playing, the call flows
down to v2_clear_queue, which stops + wipes the current slot too —
no more stale track lingering in NOW PLAYING across restarts.
- setOnTrackEnded's "queue ran out + no repeat" branch now also
calls clearQueue({includeCurrent: true}) after stopPlayback(). The
track that just finished is already off; wiping the current index
stops it from being re-parked by any subsequent queue snapshot.
cc
Leftover from Task 5.3 when the mixtape-detail route rendered a
"Full detail view is built in Phase 6" hint. Phase 6 replaced the
placeholder with MixtapeCollectionDetailView; the .hint selector
has had no consumer since then. svelte-check flagged it; removed.
cc
The CollectionsView shipped with a secondary CTA that was stubbed to
a console.log pending an artist picker we never built. Shipping a
dead button is worse than not having it. Artist Collections remain
buildable via the circular action button on ArtistDetailView (next
to Follow / Radio) — that path actually works. A future iteration
can add the picker and reintroduce the CTA if it proves useful.
cc
Cross-source artist discography builder. Fetches the artist's Qobuz
releases (via v2_get_artist_page) and LocalLibrary albums by artist
name (via v2_library_get_albums filtered client-side), normalizes
titles to group variants of the same album, ranks each group by
quality_score (bit_depth × 10M + sample_rate_hz + format_bonus), and
renders a primary row + indented alternate rows per group.
Compilations detected heuristically (best of / greatest hits /
anthology / ...) and unchecked by default. Default selection: primary
of each non-compilation group. User can check alternates to include
them too. Order by release date / title / manual (preserves input
order). Submit creates an artist_collection with
source_type='artist_discography' + source_ref=artistId and enqueues
items in display order.
ArtistDetailView gains a "Build Artist Collection" action button
(LibraryBig icon, secondary style, rendered only when callback prop is
present). Entry point from CollectionsView left as-is (artist picker
deferred). Added 'discography-builder' to ViewType union.
Toast keys toast.collectionCreated / toast.collectionCreateFailed
added to all five locale files (en, es, de, fr, pt).
cc
Adds a shared modal-trigger store + mounts AddToMixtapeModal once at
the page level. AlbumCard exposes the action via its overflow menu;
AlbumDetailView exposes it as a secondary action button in the top
action row. Both build an AddToMixtapeItem from the album's source /
id / title / artist / artwork / year / track count and invoke
openAddToMixtape(), which the global modal consumes.
cc
Full detail page with a CollectionMosaic header, title, kind tag,
description, item count, and three primary actions (Play / Shuffle /
overflow). Overflow menu exposes Rename, Edit description, Toggle
play mode, Convert kind (disabled for artist_collection), and Delete
with a confirmation dialog.
Item table columns: # | Item (artwork + title + subtitle) | Type |
Source | Quality | Tracks | Year | ⋯. Type column uses a lucide icon
+ uppercase letter-spaced label (no pill). Source reuses <SourceBadge>.
Quality reuses the shared QualityBadge compact component.
Inline per-row overflow menu offers Remove. "Play next" and full
item drag-reorder are stubbed for future work.
+page.svelte's Task 5.3 placeholder is replaced with this component;
onBack returns the user to the matching grid view (Mixtapes or
Collections) based on the collection's kind. getCollection import
removed from +page.svelte (now only used inside the detail component).
cc
Replaces the pre-5.B "Coming soon" placeholders with the real grid
views. Adds an inline create-modal (name + kind toggle) that calls
createCollection and navigates straight to the new collection's
detail placeholder — which itself stays minimal until Phase 6
ships MixtapeCollectionDetailView. Collections' "Build Artist
Collection" secondary CTA is wired to a TODO comment pending
Phase 8 DiscographyBuilderView. Drops dead .coming-soon CSS.
cc
Adds a top-level collapsible nav section "My QBZ" in the sidebar
containing two children — Mixtapes and Collections — that route to
"Coming soon" placeholder views until the full views land in Phase 5.
The parent's display is user-customizable:
- Label (default "My QBZ") via right-click -> Rename
- Icon (default /static/my-qbz.svg) via right-click -> Change icon,
which opens a native file dialog; selected path is stored as an
absolute filesystem path, rendered through convertFileSrc() at
display time.
Both preferences are per-user localStorage via the existing
userStorage.ts wrapper. Collapsed/expanded state also persists.
reloadMyQbzNav() is invoked post-login alongside the other per-user
store resync calls.
No command or internal identifier changes — the nav children identify
as 'mixtapes' / 'collections' in currentView, matching what the
Phase 5 views will expect.
cc
Move the copy-lyrics action from the sidebar header into the controls
popover footer, alongside Reset. The sidebar header is back to a single
Settings trigger. Footer layout uses space-between so Copy sits on the
left and Reset on the right, both sharing the same button style.
Add reloadLyricsDisplay() to the store and call it from +page.svelte
right after resyncPersistedVolume. The store is initialized at module
load time (before login), so the first read falls back to the unscoped
storage key. Without this reload, the logged-in user's saved
preferences — including auto-follow — were never picked up after an
app restart, leaving the sidebar stuck on defaults.
cc
User feedback from live testing: even with gapless working correctly,
the cold first-play of an offline CMAF bundle takes ~500ms to decrypt
on AES-NI hardware, 5-6s on the software fallback. No visible
indicator of what was happening — UX felt like the app was stalling.
Solution: when a track is being decrypted, replace its play glyph in
the track row with an animated padlock (lucide-svelte Lock icon +
CSS keyframe shake/scale). Tells the user "we're unlocking your
content", which is literally what's happening.
Implementation:
- Backend: new helper `load_cmaf_bundle_with_ui_events` wraps the
spawn_blocking decrypt with two Tauri events:
- `offline:unlock_start { trackId }`
- `offline:unlock_end { trackId, success }`
The helper takes two ids: `display_track_id` (what the frontend
knows this track as — Qobuz id or library row id depending on
flow) and `cmaf_track_id` (the Qobuz id used to key the bundle
inside load_cmaf_bundle itself). Same track shows the animation
whether clicked from Qobuz view or Local Library.
- All three v2 decrypt call sites now route through the helper:
v2_play_track offline branch, v2_play_next_gapless (both the
Qobuz-id and Library-qobuz_download branches), and
v2_library_play_track.
- Frontend: new store `unlockingStore` maintains a `Set<trackId>`
updated from the events, with subscribe/isUnlocking API. Started
from +page.svelte alongside the existing player/queue pollers.
- TrackRow.svelte: new `isUnlocking` reactive state derived from
the store, a new branch in the play-number template that renders
the Lock icon with `lock-shake` animation, scoped CSS keyframes
(qbz-unlocking — slight rotate + scale + opacity pulse at 1.2s
loop, accent-primary colour, cursor: progress).
The animation fires on cold decrypt only; warm L1/L2 hits bypass the
decrypt entirely and don't emit the events, so the user never sees
the padlock for a cached track — correct behavior.
cc
/label/get is gone from the Qobuz v9.7.0.3 Android app (source:
c30/b.java doesn't declare it anymore). With the Qobuz beta rollout
the server-side deprecation timer is probably running, so we yank our
dependency now.
Removed:
- LABEL_GET path constant (qbz-qobuz + src-tauri/api)
- get_label() client method (qbz-qobuz + src-tauri/api)
- get_label() wrapper in QbzCore + CoreBridge
- v2_get_label tauri command + its lib.rs registration
- LabelDetail backend type (qbz-models + src-tauri/api/models)
- commands::search::get_label (dead, was never registered)
- Unused LabelDetail import in src/routes/+page.svelte
Preserved:
- Frontend LabelDetail interface (it's a UI view-model shaped by
LabelReleasesView, not a backend contract)
- /api/labels/{id} HTTP route on qbzd — now composes /label/page +
/label/getAlbums internally so external HTTP clients still see the
same { id, name, description, image, albums: { items, total, ... } }
shape
cc
Fix#326 — multi-track selection in Local Library. Works on the tracks
tab and inside the album detail view (the more useful context per user
feedback). Bulk play next / play later / add to playlist.
Mirrors the pattern already used in FavoritesView so users can select
multiple local tracks and bulk-add them to the queue (play next / play
later) or to a playlist — the same way it works for Qobuz favorites
and playlists.
- New `trackSelectMode` + `selectedTrackIds` state + handlers (toggle
mode, toggle row, bulk play next/later, bulk add to playlist).
- New `SquareCheckBig` toolbar button on the tracks tab (matches the
one in FavoritesView).
- VirtualizedTrackList wired with `selectable`, `selectedIds`,
`onToggleSelect` props (already supported, just unused here).
- `<BulkActionBar>` rendered below the list when selection is active.
- New `onBulkAddToPlaylist` prop, wired in +page.svelte to
`openAddToPlaylist(trackIds, true)` so the existing playlist picker
modal handles the rest.
- Bulk play next/later go through `cmdAddTracksToQueue` /
`cmdAddTracksToQueueNext` (route to qbzd when remote, otherwise local
bulk command). Plex tracks are filtered out only for "add to
playlist" (per existing per-row guard); they're fine in the queue.
cc
The frontend was resolving queue-panel clicks via
`actualIndex = current_index + trackIndex`, mixing the unshuffled
canonical position with the shuffle-aware display position. On shuffle
mode, the resulting index pointed at a different track than the one
the user clicked, which is exactly what issue #327 reports.
Solution:
- New `QueueManager::play_upcoming_at(upcoming_index)` resolves the
click position through `shuffle_order` + `shuffle_position` when
shuffle is on, and falls back to `current_index + upcoming_index + 1`
otherwise.
- Exposed through `QbzCore::play_upcoming_at` (emits QueueUpdated) and
`CoreBridge::play_upcoming_at`.
- New `v2_play_queue_upcoming_at` tauri command.
- `QueuePanel.onPlayTrack` now carries the `originalIndex` so the
caller knows the exact upcoming-list position the user clicked.
- `handleQueueTrackPlay` calls the new command directly — no more
client-side index arithmetic.
Also deletes the two dead `From<crate::queue::QueueTrack>` impls in
commands_v2/queue.rs (canonical queue lives in qbz-player now), per
the zero-legacy V2 policy.
Tests: two new unit tests cover the linear and shuffle-aware paths.
Session persistence is untouched — the new method only consults
existing state.
cc
- Add core:window:allow-set-title to capabilities/default.json. Tauri 2
rejects setTitle() from the frontend unless the capability is
explicitly granted; without this the window-title feature silently
fell back to "QBZ" in every case.
- Login logo: 175px -> 140px (-20%) per feedback.
- Drop the debug console logging on the title effect now that the
wiring is confirmed working; keep the warn-on-failure.
cc
Add an opt-in setting under Appearance that lets the OS window title
reflect the currently playing track. Template supports {artist},
{track}, {album} placeholders; defaults to "{artist} — {track}".
Falls back to "QBZ" when disabled or with no track.
Persisted to localStorage (pure UX preference — no backend state).
cc
The previous infinite play fix only patched the manual "Next" button
handler. Tracks ending naturally go through the setOnTrackEnded
callback, which had two problems:
1. The early-exit guard `if (!isAutoplayEnabled())` evaluated to true
for the new `infinite` autoplay mode (since isAutoplayEnabled only
returns true for `continue`), so playback stopped after the current
track instead of advancing.
2. Even if it had advanced, the queue-end branch just stopped playback
without checking infinite play.
Fix:
- Allow auto-advance for both `continue` and `infinite` modes; only
`track_only` stops after the current track.
- When `nextTrackGuarded()` returns null AND infinite play is on,
collect last 5 played tracks (current + history), call
v2_create_infinite_radio, append the result via v2_bulk_add_to_queue,
and retry — same logic that already exists in the manual Next path.
Closes the natural-end gap reported during e2e testing of #319.
cc
Issue #319: Infinite Play toggle had no backend wiring after the V2
migration — toggle only wrote to localStorage and the queue-end handler
ignored it, so playback stopped at the last track.
Reimplemented in V2 (no legacy wrappers):
- New `v2_create_infinite_radio` command that takes the last few played
track IDs, uses the most recent track's artist as the radio seed via
`RadioPoolBuilder::create_artist_radio` (Qobuz `/radio/artist`
endpoint), and returns 50 V2QueueTrack ready to append. Does NOT
mutate the queue or playback context — caller decides how to use them.
- `playbackPreferencesStore`: expose `'infinite'` in `AutoplayMode`
type and add `isInfinitePlayEnabled()` helper.
- `+page.svelte`:
- `handleToggleInfinitePlay` now syncs to backend via
`setAutoplayMode('infinite' | 'continue')` so the choice persists
in `playback_preferences.db` across restarts (was localStorage only).
- On startup load infinite play from cached preferences (was
localStorage).
- When `nextTrack()` returns null AND infinite play is on: collect
the current track + last 5 history tracks, call
`v2_create_infinite_radio`, append the result via
`v2_bulk_add_to_queue`, then advance — playback continues without
interruption.
i18n: add `toast.failedToSavePreference` to all 5 locales.
cc
The inset dark frame + inset highlight + outer drop shadow combo
didn't produce a convincing edge on WebKitGTK — rounded corners are
the only piece of "match system chrome" that actually works today, so
drop the rest and leave the CSS minimal.
Real native shadow/border around a CSD Tauri window requires hints
like `_GTK_FRAME_EXTENTS` (X11) or `zxdg-toplevel-decoration-v1`
(Wayland) that wry doesn't expose yet. That path stays as a Phase 3
TODO — either upstream wry/tao or an FFI to gtk::Window via
raw_window_handle.
cc
Per user request, put explicit window edges above the ambient drop
shadow. Restore clip-path for clean corner anti-aliasing (it doesn't
clip inset shadows, only outer paints), drop the body padding
workaround, and paint two inset bands on .app:
inset 0 0 0 1px rgba(0,0,0,0.75) — crisp dark frame, 1px
inset 0 0 0 2px rgba(255,255,255,0.06) — subtle inner highlight
Keep a low-weight outer drop shadow (0 8px 20px) so the window still
has a hint of lift, but the edges — not the shadow — define the
window boundary now. Works the same on any compositor because it's
all painted by the webview, not by KWin/Mutter.
cc
Phase 2 produced clean rounded corners but no visible shadow because
.app filled 100vw/100vh, leaving no pixels for box-shadow to paint
into — and the clip-path added for anti-alias was clipping the shadow
as well.
Reserve a 12px padding on body while match-chrome-transparent is active
and shrink .app by the matching 24px in width/height, so the outer
drop shadow has somewhere to render. Drop clip-path (it was consuming
the shadow); keep transform: translateZ(0) for GPU AA on the corners.
Two-layer shadow: a broad soft ambient (18/40 rgba(0 0 0 / 0.65)) plus
a tight 4/12 crisp shadow for edge definition without painting a hard
1px stroke that jaggies at the rounded joint.
cc
Remove the hard 1px outline that the "match system window chrome"
style was painting inside the radius: on WebKitGTK the outline
amplified anti-aliasing jaggies at the rounded corners because the
stroke has no blur. Replace with a softer two-layer drop shadow,
add clip-path: inset(... round ...) on top of border-radius (clip-path
gives noticeably cleaner AA than border-radius + overflow:hidden alone
in WebKitGTK), and promote the container to its own compositing layer
with translateZ(0) + backface-visibility:hidden so the GPU handles the
edge.
cc
Rounded-corner window chrome now actually works. The toggle in
Settings > Appearance persists to the window_settings SQLite store and
gates the Tauri window transparency decision at startup, so on next
launch the main window is rebuilt with `transparent: true` and the
border-radius plus edge outline apply without painting white corners.
Flow:
* Toggle in Settings > Appearance -> windowChromeStore saves to
localStorage and calls v2_set_match_system_window_chrome which
writes match_system_window_chrome to window_settings.db.
* Toast reminds the user to restart.
* On next launch, should_use_main_window_transparency() reads the
setting and returns true, Tauri builds the window transparent.
* Setup stores the decision in MAIN_WINDOW_TRANSPARENT. A new command
v2_main_window_is_transparent reports it back to the frontend.
* +page.svelte only applies .app.match-chrome when the window is
actually transparent AND the custom titlebar is visible, so
stale-state toggling never produces white wedges.
* CSS sets html/body transparent via a global class on <html>, then
.app.match-chrome.floating paints the rounded corners plus a
12/28 rgba drop shadow and a 1px darkening outline.
* Maximized state clears the radius automatically.
Env vars QBZ_FORCE_TRANSPARENT_WINDOWS / QBZ_FORCE_OPAQUE_WINDOWS still
take precedence over the DB-persisted toggle.
cc
Adds an opt-in "Match system window chrome" toggle in Settings >
Appearance. When enabled (and the custom title bar is active, i.e.
neither "Use system title bar" nor "Hide title bar"), QBZ rounds its
own corners to match the detected desktop's decoration radius and
paints a thin edge outline so the window reads as its own surface
against the desktop instead of a bare rectangle.
Phase 1 scope:
* New windowChromeStore (localStorage) holding the toggle and the
detected corner radius.
* desktop_theme module gains a best-effort window_corner_radius_px:
10 for Klassy, 6 for Breeze Plasma, 12 for GNOME/Adwaita, 8 fallback.
* +page.svelte subscribes to the detector; .app.match-chrome.floating
applies the radius via a CSS variable plus a 1px darkening outline
and a deeper shadow. Maximized state removes the radius.
* SettingsView: new toggle, disabled when hideTitleBar or
useSystemTitleBar is on; i18n for en/es.
External shadow that matches each DE's compositor remains a phase 2
item — it requires `transparent: true` on the Tauri window, which has
compositor stability trade-offs and must be restart-gated.
Also cleans up redundant AlbumCard ribbons in the Editor's Picks
TAB (distinct from the home tab that was fixed earlier): removes
ribbon={album.ribbon} from the `editorPicks` and `qobuzissimes`
sections inside the editor-picks tab block. Press Accolades and
Popular Albums keep their ribbons (they vary per card).
cc
Root cause traced from user diagnostic: only one playback:state event
fired (position=0), then nothing. Backend's current_position() returned
0 forever because thread_state.duration was 0 — the PlayStreaming
handler at player/mod.rs:1849 stores duration verbatim from the
command parameter and current_position() at player/mod.rs:789 ends
with .min(duration), clamping to 0.
Parameter names also didn't line up. cmdPlayTrack was invoking
v2_play_track with qualityStr and lowQualityFallback, neither of
which matches Tauri's camelCase mapping of the Rust signature
(quality, forceLowestQuality, durationSecs). Quality and force-fallback
were being silently ignored; durationSecs was never sent at all.
The normal queue playback path in playbackService uses the correct
camelCase names and passes track.duration, which is why the seekbar
worked everywhere except the session-restore first play — that path
goes through togglePlayback → cmdPlayTrack.
Fix:
- Correct the parameter names in cmdPlayTrack to match the backend
(quality, forceLowestQuality, durationSecs).
- Add durationSecs as a first-class arg and pass it from
togglePlayback when resuming a restored track.
- Drop the temporary [Diag:*] console logs added while investigating.
cc
Level A of issue #307. When the configured output device has
disappeared from the host device list at the time of play/resume
(KVM switched away, USB sink unplugged, PipeWire sink removed…)
the backend emits an 'audio:device-missing' event and the frontend
shows a warning toast:
'Output device "XYZ" is no longer available. Using system default.'
The existing init_device fallback already routes audio to the system
default, so this is purely informational — the user now understands
why audio came out of a different sink instead of staring at a silent
progress bar.
Does NOT touch the sacred audio init path (pipewire_backend.rs,
init_device, audio_settings.rs). New module audio_device_watch.rs
adds a pure presence check, a new-state throttle so we don't emit on
every resume once the user's been notified, and a v2 command
v2_check_audio_device_presence so the frontend can poll on demand.
Ready for Level B (background watchdog) — the same helpers plug into
a timer without further refactor; Level B only needs to add a tokio
task that calls emit_missing_if_needed() every N seconds while
playback is active.
cc
1. Press ribbon restyled to a solid gold gradient (#f5c042 → #d49511)
with dark (#1f1407) uppercase text and a subtle white text-shadow.
Drops the red border-left. Qobuzissime / Album of the Week keep
their dark-background treatment with coloured left accent.
2. AwardView now loads /award/explore on mount (first 30 entries,
current award filtered out) and renders them as a horizontal
'Other awards' carousel below the album grid — same layout as
LabelView's 'More labels'. Each card shows the award laurel
(or magazine cover when present) + name + magazine name; clicking
routes into that award's AwardView.
i18n key award.otherAwards added for all five locales.
cc
- Console-dumps album and album.awards when AlbumView loads so we can
see exactly what Qobuz returns for a given album (user reported
Discoteca Ideal Qobuz still not resolving even after /award/explore
cached 15 entries).
- Adds rememberAward / rememberAwards / rememberAwardsFromAlbums to
the catalog store plus sessionStorage persistence. Wiring these
into the data paths comes next once we know what shape /album/get
actually returns for awards.
cc
AwardView now shows a single grid of the first 20 albums with a See
all link in the section header (visible only when there are more
albums behind it). Grid layout stays — no horizontal carousel.
Clicking See all navigates to a new 'award-albums' view
(AwardAlbumsView) with a full paginated list: header with kicker +
title, top-right search, virtualized grid with load-more. Same
structure as LabelReleasesView for visual consistency.
cc
/album/get's AwardDto has id: String? — Qobuz sometimes returns an
award entry with only a name and no id. Rather than disabling the
click or toasting, we look up the id in the Qobuz award catalog.
- Adds v2_get_award_explore (paginated /award/explore) all the way
through qbz-qobuz → core → bridge → command → invoke_handler.
- New frontend awardCatalogStore caches the full (id, name) list
lazily, one fetch per session, resolved on first click that needs
it. Further clicks are instant.
- handleAwardClick goes straight to navigation when id is present,
falls back to resolveAwardIdByName otherwise. Toast only fires if
the name isn't in the catalog either (genuinely unknown award).
cc
Previously disabled={!onAwardClick || !award.id} grayed out the award
card whenever /album/get returned an award entry without an id. User
reported the award was 'no longer clickable' on albums opened from the
artist page. Now the button always accepts clicks and the handler
shows a toast when no id is available instead of silently doing
nothing — keeps the affordance honest without sending 'null' to
/award/page.
cc
The log showed 'award=null' reaching /award/getAlbums and Qobuz
replying '400 Bad Request: Invalid argument: award_id (accepted type
are number)'. Root cause: AlbumAward.id was typed Option<u64> and
/album/get returns award ids as strings; serde dropped them to None,
frontend stringified null → 'null', we sent that as the query param.
Fix:
- AlbumAward.id becomes Option<String> with a custom deserializer
that accepts both string and integer JSON values.
- TS types + AlbumDetailView Award interface + pickAlbumRibbon all
use string id (compared as '88'/'151' now).
- Sidebar button is disabled when id is falsy; handleAwardClick
early-returns on empty id so we never navigate to award 'null'.
- Better body/status logging in get_award_albums stays so future
4xx responses are visible in the log.
Also switches /award/page and /award/getAlbums to signed_get_auth
(app_id + user token). Mobile does the same — catalog visibility is
region-scoped and the unsigned variant returns 400.
cc
Two behavior fixes for AwardView based on user feedback.
1. Grid content. The previous implementation rendered the releases
array from /award/page, which is user-scoped (only surfaces awards
on albums the user already favorited), so the grid came up empty
in almost every case. Switch to /award/getAlbums which is the
public catalog listing with pagination. /award/page is still
called, but only to hydrate the hero (name, image, magazine).
2. Follow-the-award. Awards themselves are followable on Qobuz mobile
(it's what feeds the 'Awards' tab of Release Watch). Add:
- favorite_awards table + store methods in favorites_cache.rs
- 4 V2 commands (get/sync/cache/uncache) mirroring the label ones
- awardFavoritesStore.ts — clone of labelFavoritesStore with string
ids (Qobuz mobile API passes award_ids as strings)
- Follow/Unfollow heart button in AwardView hero, matching the
ArtistDetailView / LabelView visual
- loadAwardFavorites() wired into the login bootstrap alongside
loadArtistFavorites / loadLabelFavorites
- i18n keys (award.follow / award.unfollow)
Also adds a Ver más button for paginated loading of the album grid.
cc
Adds the frontend for award detail pages. The hero header mirrors
LabelView's proportions 1:1 — 180px circular avatar left, inline
subtitle/name/magazine stack right, same 32px heading, same 12px
uppercase kicker, same 24px gap, same 40px bottom margin, same back
button treatment. Outer container uses the same paddings and scroll
behaviour as LabelView/AlbumDetailView.
Body pulls /award/page and builds one HorizontalScrollRow per
container (releases, new-releases, classics…). Container titles
localize via award.section.<id> with fallback to 'Acclaimed Releases'.
Wiring:
- 'award' added to ViewType and session restore map
- handleAwardClick in +page.svelte navigates to the view
- AlbumDetailView sidebar now passes onAwardClick so Phase 2 award
cards become interactive
- Frontend AlbumAward.id is optional to match tolerant backend struct;
sidebar disables the award button when id is absent
- i18n keys added in all five locales
cc
Ports the Qobuz mobile 'Radar de Novedades' / Release Watch screen 1:1
(see rr0/i.java): a standalone view with three lazy-loaded tabs —
Artists, Labels, Awards — each hitting /favorite/getNewReleases with
the matching type parameter (limit 150 per tab, matching the mobile
cap).
Home's Release Watch rail (which still shows the Artists feed by
default, matching the mobile's default tab) now has a 'See all' link
that routes to the new view.
Adds view 'discover-release-watch' to the navigation type, wires the
route in +page.svelte, and adds tab/empty-state i18n keys in all five
locales.
cc
Introduces 'labels' as a first-class favorites tab alongside
tracks/albums/artists/playlists. Adds the navigation view
'favorites-labels', plumbs it through sidebar, titlebar dropdown,
favorites preferences modal and navigation store, and renders a
circular grid of followed labels with search and empty states.
Clicking a card navigates to the existing LabelView detail page.
Spanish copy uses 'Sellos' and Portuguese 'Selos' to match
Qobuz mobile terminology.
cc
Adds the same follow flow we already have for artists, applied to
labels. Confirmed against the inferred OpenAPI spec
(qbz-nix-docs/qobuz-api-inferred-openapi-v9.7.0.3.yaml) — the
existing /favorite/create, /favorite/delete and
/favorite/getUserFavorites endpoints already accept fav_type='label'
and label_ids, so no new Qobuz endpoints were needed.
Backend
- favorites_cache: new favorite_labels SQLite table + add / remove /
sync / get methods alongside the artist counterparts. clear_all
drops the new table on logout.
- commands_v2::favorites: 4 new commands —
v2_get_cached_favorite_labels, v2_sync_cached_favorite_labels,
v2_cache_favorite_label, v2_uncache_favorite_label. Same wiring as
the artist commands.
- lib.rs invoke_handler registers the four new commands.
Frontend
- src/lib/stores/labelFavoritesStore.ts: clone of
artistFavoritesStore — same in-memory Set, same isLoaded flag,
toggle calls /favorite/create with fav_type=label, sync paginates
/favorite/getUserFavorites?type=labels.
- routes/+page.svelte: loadLabelFavorites() called alongside the
other three after login is confirmed.
- LabelView.svelte: heart-shape Follow button in the label header
info column, mirroring ArtistDetailView's .artist-actions
treatment. Reactive to the store via labelFavoritesVersion bump
so any future Favorites view that toggles a label will keep this
view in sync.
- i18n: label.follow / label.unfollow in all five locales.
cc
Additively wires the in-app auto-update flow: on +page.svelte the
Update Available modal offers Download & Install when eligible, and
on SettingsView the Check for Updates result modal does the same.
Both show the UpdateProgressModal while the update runs, with
Cancel (pre-install) and Download manually fallback (on error).
No existing behavior is modified: nextTrackGuarded, volumeLocked,
repeat-one handling, player bar height variables, and the platform
helper all remain untouched.
cc
Banner inside now-playing-bar grows it to 128px. CSS variable
--player-bar-height drives content-area/main-content calc() so
layout adjusts automatically. No more overlap.
Fix disconnect button text: actions.disconnect -> settings.integrations.disconnect
cc
The NowPlayingBar correctly locked the volume slider but the immersive
player (fullscreen/focus mode) was missing the volumeLocked prop chain,
allowing the slider to move visually even though the volume change was
blocked. Respects Qobuz Connect peer renderer override.
cc