Commit Graph
512 Commits
Author SHA1 Message Date
vicrodh 694f1a1044 refactor(qconnect): rename NowPlayingBar prop to isQobuzConnectToggleOn
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
2026-04-25 21:15:26 -06:00
vicrodh 23658b053c fix(qconnect): decouple toggle state from transport so user can disable a stuck reconnect
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
2026-04-25 21:02:30 -06:00
vicrodh f9b7290e90 fix(playlist-detail): route plex tracks through v2_plex_play_track
Plex tracks added to a Qobuz playlist weren't playable:

  * Clicking the row hit onTrackPlay → playTrack with no source arg →
    backend treated the id as a Qobuz track id and rejected it with
    "invalid track id for remote handoff: -1000017964" (the negative
    plexDisplayId UI-only offset was the id we sent).
  * Auto-advancing from the previous (local) track mapped the plex
    DisplayTrack's negative id through Math.abs into the local-track
    queue, which then looked 1_000_017_964 up in local_tracks and
    errored with "Track not found".

Changes:

1. plexTrackToDisplay now uses Number(ratingKey) as id when the
   ratingKey parses as a positive integer, matching the shape Plex
   backend / LocalLibraryView already use (PlexCachedTrack.id =
   playback_track_id(rating_key) = the numeric rating_key for the
   common case). The negative-offset hash stays as the fallback for
   non-numeric rating keys — those still need UI-unique ids but are
   rare enough that no-play is an acceptable degradation.
2. isLocal stays true so the Qobuz context-builder and blacklist
   filter keep their existing semantics; the new isPlex flag drives
   routing at the dispatch sites.
3. PlaylistDetailView.Props gains onPlexTrackPlay (track) → void.
   handleTrackClick checks track.isPlex first and dispatches through
   it; handleTrackPlayNext / handleTrackPlayLater no-op on plex for
   now (backend queue doesn't understand plex yet).
4. removeTrackFromPlaylist checks track.isPlex first and calls
   v2_playlist_remove_plex_track with ratingKey: String(track.id) so
   the "Remove from playlist" menu item works on plex rows too.
5. +page.svelte gets handleDisplayPlexTrackPlay — mirrors
   handleLocalTrackPlay's plex branch (playTrack with source='plex',
   isLocal:false). Wired via onPlexTrackPlay on the PlaylistDetailView
   render.
6. buildQueueTracks filters plex out entirely. The local-track queue
   path in the backend looks ids up in local_tracks; plex ratingKeys
   aren't there and the mismatch was the source of the auto-advance
   "Track not found" toast. Plex tracks keep their direct-click
   playback; gapless auto-advance across plex will require backend
   queue awareness (deferred).

Known limitation carried forward: Play Next / Play Later / auto-
advance across plex tracks are no-ops until the backend queue learns
to cross-reference plex_cache_tracks. Direct click on a plex row
plays correctly.

cc
2026-04-21 15:22:41 -06:00
vicrodh d5ff51aba0 feat(playlist-modal): route Plex tracks through the dedicated add path
Frontend half of the fix. LocalLibraryView no longer gates Plex
tracks out of "Add to playlist" — the whole add pipeline now routes
by source:

  * source === 'plex' → v2_playlist_add_plex_track(playlistId,
    ratingKey, position). ratingKey comes from track.file_path, which
    is where mapPlexTrack stashes it at load time.
  * source in {user, qobuz_purchase, qobuz_download} →
    v2_playlist_add_local_track(playlistId, localTrackId, position)
    (unchanged).
  * source === qobuz (Qobuz catalog track) →
    v2_add_tracks_to_playlist (unchanged).

Changes:
  * uiStore: new playlistModalPlexRatingKeys slot + getter + passed
    through openPlaylistModal (4th arg) and closePlaylistModal.
  * PlaylistModal: new plexRatingKeys prop, isPlexTracks derived flag,
    and branches in handleAddToPlaylist / handleDirectAddToPlaylist /
    handleCreateAndAdd that emit v2_playlist_add_plex_track. Duplicate
    detection (which only covers Qobuz) is skipped for Plex adds.
    tracks_count update for newly-created playlists now treats Plex
    like local — 0, since the Qobuz side doesn't own those tracks.
  * LocalLibraryView: adds onTrackAddPlexToPlaylist /
    onBulkAddPlexToPlaylist callbacks. handleBulkAddToPlaylist and
    handleAlbumBulkAddToPlaylist split the selection by source and
    emit the right callback per subset. The per-row plex gate at
    line 3509 is replaced with source-aware routing.
  * +page.svelte: new openAddPlexToPlaylist helper + wired the two
    new callbacks into the LocalLibraryView render.

No confusion with Qobuz tracks: Plex rating keys never touch the
Qobuz playlist API, and local-track ids never reach the Plex
command. The two namespaces stay physically separate at every layer.

cc
2026-04-21 11:38:25 -06:00
vicrodh 8b90ac7245 fix(sidebar): hide My QBZ section when it lives in the titlebar
The sidebar already respected the titlebar-owned flag for Discover,
Favorites, Library, and Purchases but not for My QBZ — toggling
"Show My QBZ in titlebar" in Settings duplicated the entry in both
places. Add a myQbzInTitlebar prop plumbed from +page.svelte
(tbNavMyQbz && showTitleBar, same pattern as the others) and gate the
sidebar block on it.

cc
2026-04-21 10:31:48 -06:00
vicrodh 68e76a203e fix(ui): wire per-album play + session source restore + NowPlayingBar artwork + My QBZ titlebar
Consolidates the +page.svelte side of several independent fixes/features
whose non-+page pieces landed in the preceding commits.

- handleMixtapeItemAction: was calling v2_set_queue with { trackIds }
  but the command expects { tracks, startIndex } — Tauri rejected the
  call so per-album Play from a Collection did nothing. Now routes
  Qobuz items through the existing fast path (with blacklist filter +
  artwork fallbacks) and local/Plex items through the new
  v2_enqueue_collection_item backend command, then resolves the queue
  via playQueueIndex(0) + playQueueTrack(trk) so audio actually starts
  (v2_play_queue_index only moves the cursor; it doesn't tell the
  player to load bytes).
- Session save (saveSessionBeforeClose): includes source on each
  PersistedQueueTrack. Without it, a LocalLibrary queue came back as
  is_local=true / source=undefined and the auto-advance path routed
  local row-ids to v2_play_track (Qobuz).
- Session restore: map session.queue_tracks with a source fallback
  (trk.source ?? (trk.is_local ? 'local' : undefined)) so older saved
  sessions (pre-source-field) still route correctly. setCurrentTrack
  default for source is now 'local' when is_local is true, not
  hardcoded 'qobuz'.
- playQueueTrack: artwork goes through resolveQueueTrackArtwork so a
  raw Plex /library/... thumb path or a bare local filesystem path
  becomes a valid src for NowPlayingBar instead of a 404.
- Title bar: isMyQbzInTitlebar state + showMyQbz prop passed to
  TitleBarNav so the new My QBZ entry can be toggled from Settings.

cc
2026-04-20 17:49:17 -06:00
vicrodh 921d5b455c feat(mixtape): wire Mixtape/Collection + DiscographyBuilder navigation
Handlers + callback wiring in the root view layer:

DiscographyBuilder
  - onOpenAlbum → handleAlbumClick (Qobuz) or selectLocalAlbum (local)
    so clicking a candidate album navigates to its detail.

Mixtape / Collection detail
  - onOpenItem for album / track / playlist rows.
  - onOpenArtist runs handleOpenArtistByName: v2_search_artists(name)
    and navigates to the top match — collection items only store an
    artist name, not an id.
  - onPlayItem / onPlayItemNext / onAddItemToQueueLater wired to a
    new handleMixtapeItemAction that fetches the item's tracks
    (Qobuz album path) and sets / appends the queue. Non-Qobuz
    item types toast a "not available yet" message.
  - Bulk variants (onBulkPlayNext / onBulkPlayLater /
    onBulkAddToPlaylist) iterate through selected items.
  - onPlayTrackFromItem (expanded-view TrackRow Play): fetches the
    parent album and jumps to the picked track's index.
  - onPlayTrackNext / onPlayTrackLater: fetch via v2_get_track and
    call v2_add_tracks_to_queue_next / v2_add_tracks_to_queue for a
    single Qobuz track.

Adds selectLocalAlbum + MixtapeCollectionItem to the existing
imports.

cc
2026-04-19 22:45:16 -06:00
vicrodh 4e618dd5b7 fix(ui): back-to-top z-index + AddToMixtapeModal items prop
- back-to-top-global button z-index 50 → 200 so no view's sticky
  footer (Collection detail BulkActionBar, DiscographyBuilder footer,
  etc.) can park on top of it.
- AddToMixtapeModal now receives items={} (array) instead of
  item={} (single) after the store + modal signature moved to the
  bulk-capable shape.

cc
2026-04-19 22:36:15 -06:00
vicrodh 7d2340a978 fix(queue): clear stale now-playing after Clear + natural end
Two related UX fixes in handleClearQueue + the setOnTrackEnded branch:

- handleClearQueue now passes includeCurrent=!isPlaying. When the
  user hits Clear while nothing is actively playing, the call flows
  down to v2_clear_queue, which stops + wipes the current slot too —
  no more stale track lingering in NOW PLAYING across restarts.
- setOnTrackEnded's "queue ran out + no repeat" branch now also
  calls clearQueue({includeCurrent: true}) after stopPlayback(). The
  track that just finished is already off; wiping the current index
  stops it from being re-parked by any subsequent queue snapshot.

cc
2026-04-19 22:35:00 -06:00
vicrodh 32c6727e2f chore: remove unused .detail-placeholder .hint CSS rule
Leftover from Task 5.3 when the mixtape-detail route rendered a
"Full detail view is built in Phase 6" hint. Phase 6 replaced the
placeholder with MixtapeCollectionDetailView; the .hint selector
has had no consumer since then. svelte-check flagged it; removed.

cc
2026-04-19 02:41:44 -06:00
vicrodh 8f02c542fd fix(collections): remove dead "Build Artist Collection" CTA
The CollectionsView shipped with a secondary CTA that was stubbed to
a console.log pending an artist picker we never built. Shipping a
dead button is worse than not having it. Artist Collections remain
buildable via the circular action button on ArtistDetailView (next
to Follow / Radio) — that path actually works. A future iteration
can add the picker and reintroduce the CTA if it proves useful.

cc
2026-04-19 02:40:20 -06:00
vicrodh 2460467e48 feat(mixtape): DiscographyBuilderView for Artist Collections
Cross-source artist discography builder. Fetches the artist's Qobuz
releases (via v2_get_artist_page) and LocalLibrary albums by artist
name (via v2_library_get_albums filtered client-side), normalizes
titles to group variants of the same album, ranks each group by
quality_score (bit_depth × 10M + sample_rate_hz + format_bonus), and
renders a primary row + indented alternate rows per group.

Compilations detected heuristically (best of / greatest hits /
anthology / ...) and unchecked by default. Default selection: primary
of each non-compilation group. User can check alternates to include
them too. Order by release date / title / manual (preserves input
order). Submit creates an artist_collection with
source_type='artist_discography' + source_ref=artistId and enqueues
items in display order.

ArtistDetailView gains a "Build Artist Collection" action button
(LibraryBig icon, secondary style, rendered only when callback prop is
present). Entry point from CollectionsView left as-is (artist picker
deferred). Added 'discography-builder' to ViewType union.

Toast keys toast.collectionCreated / toast.collectionCreateFailed
added to all five locale files (en, es, de, fr, pt).

cc
2026-04-18 20:57:02 -06:00
vicrodh 6615f6bbe3 feat(mixtape): "Add to Mixtape/Collection" on AlbumCard and AlbumDetailView
Adds a shared modal-trigger store + mounts AddToMixtapeModal once at
the page level. AlbumCard exposes the action via its overflow menu;
AlbumDetailView exposes it as a secondary action button in the top
action row. Both build an AddToMixtapeItem from the album's source /
id / title / artist / artwork / year / track count and invoke
openAddToMixtape(), which the global modal consumes.

cc
2026-04-18 20:38:22 -06:00
vicrodh 9068b2bf14 feat(mixtape): MixtapeCollectionDetailView
Full detail page with a CollectionMosaic header, title, kind tag,
description, item count, and three primary actions (Play / Shuffle /
overflow). Overflow menu exposes Rename, Edit description, Toggle
play mode, Convert kind (disabled for artist_collection), and Delete
with a confirmation dialog.

Item table columns: # | Item (artwork + title + subtitle) | Type |
Source | Quality | Tracks | Year | ⋯. Type column uses a lucide icon
+ uppercase letter-spaced label (no pill). Source reuses <SourceBadge>.
Quality reuses the shared QualityBadge compact component.

Inline per-row overflow menu offers Remove. "Play next" and full
item drag-reorder are stubbed for future work.

+page.svelte's Task 5.3 placeholder is replaced with this component;
onBack returns the user to the matching grid view (Mixtapes or
Collections) based on the collection's kind. getCollection import
removed from +page.svelte (now only used inside the detail component).

cc
2026-04-18 20:33:36 -06:00
vicrodh 8f5c05d3cb feat(mixtape): wire MixtapesView and CollectionsView into the page router
Replaces the pre-5.B "Coming soon" placeholders with the real grid
views. Adds an inline create-modal (name + kind toggle) that calls
createCollection and navigates straight to the new collection's
detail placeholder — which itself stays minimal until Phase 6
ships MixtapeCollectionDetailView. Collections' "Build Artist
Collection" secondary CTA is wired to a TODO comment pending
Phase 8 DiscographyBuilderView. Drops dead .coming-soon CSS.

cc
2026-04-18 20:20:36 -06:00
vicrodh 8466a61967 feat(ui): My QBZ collapsible nav wrapper with Mixtapes and Collections children
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
2026-04-18 19:57:35 -06:00
vicrodh 7480df9ede fix(lyrics): move copy button into popover and reload prefs after login
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
2026-04-18 12:25:08 -06:00
vicrodh a7e2a8e911 feat(ui): animated padlock on track row while offline CMAF decrypt runs
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
2026-04-17 14:11:44 -06:00
vicrodh 6de109b3b8 refactor(labels): purge legacy /label/get stack
/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
2026-04-16 22:59:31 -06:00
vicrodh e83caa7648 Merge branch 'feature/internal/local-library-multiselect-326' into pre-release
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.
2026-04-16 22:26:15 -06:00
vicrodh 261676550c feat(local-library): multi-track selection on tracks tab (#326)
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
2026-04-16 20:16:02 -06:00
vicrodh 5656bbe2ab fix(queue): clicking a track respects shuffle order (#327)
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
2026-04-16 20:04:24 -06:00
vicrodh fa7758f5bb fix(window,login): grant set-title capability + shrink login logo 20%
- 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
2026-04-16 09:48:43 -06:00
vicrodh ace9e93b84 feat(window): optional track metadata in window title (#324)
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
2026-04-16 08:58:03 -06:00
vicrodh f7f32e014a fix(queue): apply infinite play to natural track-end auto-advance
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
2026-04-15 22:09:57 -06:00
vicrodh 33d65681c4 fix(queue): restore infinite play (V2 reimplementation)
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
2026-04-15 21:20:04 -06:00
vicrodh 7d1e5604da refactor(appearance): keep only the rounded corners on match-chrome
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
2026-04-15 12:20:22 -06:00
vicrodh 4c5019dff3 fix(appearance): prioritize visible edge over drop shadow
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
2026-04-15 12:12:21 -06:00
vicrodh 74d4d98e42 fix(appearance): draw the external drop shadow around rounded window
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
2026-04-15 12:10:57 -06:00
vicrodh 823e1c313e fix(appearance): smoother rounded corners under WebKitGTK
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
2026-04-15 11:51:03 -06:00
vicrodh 57e63e3ef5 feat(appearance): phase 2 of match-system-window-chrome (transparent)
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
2026-04-15 11:31:11 -06:00
vicrodh 8502c2fdb3 feat(appearance): match system window chrome (phase 1) + ribbon cleanup
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
2026-04-15 11:17:05 -06:00
vicrodh 6094876aa1 fix(player): pass durationSecs on session-restore first play so seekbar advances
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
2026-04-14 18:57:20 -06:00
vicrodh b62a4b01f5 feat(audio): notify user when selected output device goes missing (issue #307)
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
2026-04-14 17:58:59 -06:00
vicrodh 7c2f2941fd feat(award): gold press ribbon + Other awards carousel on AwardView
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
2026-04-14 16:07:22 -06:00
vicrodh d75a8505b0 debug(album): log full /album/get payload; catalog can harvest award ids from album responses
- 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
2026-04-14 15:52:33 -06:00
vicrodh 6a5f9d0901 feat(award): add See all link and dedicated AwardAlbumsView
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
2026-04-14 15:12:46 -06:00
vicrodh 854eaaa0a4 feat(award): resolve award id by name via /award/explore when /album/get omits it
/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
2026-04-14 15:06:15 -06:00
vicrodh 9460838223 fix(album-sidebar): keep award card always clickable; toast if id missing
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
2026-04-14 14:51:54 -06:00
vicrodh 037e18423a fix(award): normalize award.id as string end-to-end so /award/* calls stop sending 'null'
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
2026-04-14 14:39:20 -06:00
vicrodh a273174716 feat(award): use /award/getAlbums for the grid + follow award as entity
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
2026-04-14 13:02:37 -06:00
vicrodh 8730c891cc feat(award): dedicated AwardView with header matching Label/Album conventions
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
2026-04-14 12:37:41 -06:00
vicrodh ebeb86d03d feat(release-watch): dedicated tabbed view mirroring Qobuz mobile
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
2026-04-14 10:52:20 -06:00
vicrodh ebe999c43d feat(favorites): add Labels tab to Favorites view
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
2026-04-14 09:28:34 -06:00
vicrodh a9a9ba5050 feat(labels): follow / unfollow labels (mirrors Follow Artist)
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
2026-04-14 09:00:11 -06:00
vicrodh 347a8c1016 feat(updater): wire auto-update handlers in app and settings
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
2026-04-13 15:00:28 -06:00
vicrodh 3a01330b02 fix(ui): banner layout uses CSS variable, fix i18n key, responsive height
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
2026-04-09 13:26:15 -06:00
vicrodh 7191309f62 Merge remote-tracking branch 'origin/pre-release' into pre-release 2026-04-08 12:14:25 -06:00
AdamArstall 56ec473e14 Merge remote-tracking branch 'upstream/pre-release' into bugfix/external/local-library-gapless 2026-04-08 17:21:24 +01:00
vicrodh b5bb2bbe03 fix(player): lock volume slider in immersive player when ALSA Direct hw active
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
2026-04-07 15:18:10 -06:00