41 Commits

Author SHA1 Message Date
joren
6c8d032ce9 feat: context menus on search panel, track info dialog, separate shuffle buttons
- Add right-click context menus to search panel tracks (play, queue,
  favorites, playlist, go to album/artist, track info) and albums
  (open, favorite, go to artist)
- Add "Track info..." dialog showing metadata (title, performer,
  composer, album, quality, hi-res status) — available everywhere:
  playlists, albums, favorites, search results
- Split artist page shuffle into "Shuffle" (popular tracks) and
  "Shuffle All" (deep shuffle across all releases)
- Remove magnifying glass emoji from welcome text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 23:57:42 +01:00
joren
a21d0c8a33 refactor: UI polish — lock sidebar, remove nav buttons, uniform artist tables, deep shuffle
- Lock sidebar width (setFixedWidth) so it doesn't jump between views
- Remove back/forward navigation buttons and all NavPage history code
- Uniform column layout on artist page: hide Artist column from both
  Popular Tracks and release sections, set matching fixed column widths
  so columns align vertically across all sections
- Deep shuffle: new Rust FFI endpoint fetches tracks from all albums
  in parallel, combines them, and returns via EV_DEEP_SHUFFLE_OK
- Auto-paginate artist releases in Rust (loop until has_more=false)
  so all releases load at once sorted newest-first

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 23:42:00 +01:00
joren
333a620be2 fix: section alignment, pagination, fav state, playback error handling
**Section toggles left-aligned**
- Replace QToolButton with flat QPushButton for all section headers;
  QPushButton properly respects text-align: left in stylesheets

**Pagination via "Load More" button**
- QTreeWidget expands to fit all items so the scrollbar-based infinite
  scroll never triggered; replaced with an explicit "Load more…" button
  that appears when has_more is true and emits loadMoreRequested

**Favourite button reflects actual state**
- MainWindow preloads fav artist IDs on session restore (getFavArtists)
  and caches them in m_favArtistIds
- ArtistView receives the full set via setFavArtistIds() and checks
  it on every setArtist() call so the button starts gold if already faved
- Toggling updates the local cache immediately for back/forward nav

**Playback error → queue advances**
- player_loop now sets track_finished on Err (was only set on Ok(None)),
  so the toolbar's onTrackFinished handler advances to the next track
  instead of stalling on an unplayable track

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 23:22:30 +01:00
joren
b3cc2e3def chore: gitignore build-viz/, remove accidentally committed build artifacts 2026-03-25 18:25:39 +01:00
joren
3e96b6d7a8 feat: pagination, back/forward nav, context menu, artist fav, image fix
**Artist portrait**
- Fix CDN URL: images.portrait.{hash,format} →
  https://static.qobuz.com/images/artists/covers/large/{hash}.{format}

**Section alignment**
- Qt::ToolButtonTextOnly on all section toggles so text is truly left-aligned

**Auth 401 race condition**
- qobuz_backend_set_token now uses blocking_lock() instead of spawning an
  async task, guaranteeing the token is set before any subsequent API call

**Pagination (infinite scroll)**
- Release sections load 50 at a time (was 500)
- ArtistSection tracks has_more + loaded count; scrolling to the bottom
  emits loadMoreRequested → ArtistView calls getArtistReleases(offset=N)
- AlbumListView gains addAlbums() for append; setReleases routes to
  setAlbums (offset=0) or appendAlbums (offset>0)

**Back/Forward navigation**
- MainToolBar exposes Back/Forward QActions (go-previous/go-next icons)
- MainWindow keeps a NavPage vector + index; pushNav() on every album/artist
  navigation; goBack/goForward re-navigate without pushing history

**Context menu on now-playing label**
- Right-click on track label in toolbar → "Go to Album" / "Go to Artist"
- MainToolBar stores current track; emits albumRequested/artistRequested
  signals wired to MainWindow's existing handlers

**Artist favourites button**
- ♡ Favourite / ♥ Favourited toggle in artist header
- Calls new addFavArtist / removeFavArtist (Rust + Qt backend wiring)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:25:12 +01:00
joren
963c9ad232 refactor: uniform artist page layout — header + collapsible sections
- Artist header now mirrors TrackContextHeader: 120×120 portrait with
  rounded rect background, large bold name, compact scrollable bio,
  Play/Shuffle buttons in the same row (gold #FFB232 style)
- Play/Shuffle moved out of Popular Tracks into the header area
- Popular Tracks becomes a collapsible toggle section identical in
  appearance to Albums/EPs/Live/Compilations/Other
- All section headers share kToggleStyle: left-aligned bold text,
  ▼/▶ arrow, subtle bottom border, hover highlight
- Portrait loading tries hash-based CDN URL first, falls back to
  image.large / image.small direct URL fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:03:47 +01:00
joren
8310eceeb2 feat: artist portrait, race condition fix, and uniform button styling
- Load artist portrait from images.portrait.hash via QNetworkAccessManager
- Fix race condition: fire getArtistReleases after setArtist() clears sections,
  not before (from onArtistLoaded instead of onSearchArtistSelected)
- Apply uniform gold (#FFB232) play/shuffle button style matching album view
- Make biography scrollable (QTextEdit with max height + scroll on overflow)
- Extend track artist name parsing to handle top_tracks {display:...} format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 14:17:36 +01:00
joren
fb58c0ac8c fix: artist page top tracks and scrollable biography
- top_tracks is a flat array in the API response, not {items:[...]}
- Replace bio QLabel with scrollable QTextEdit (max 110px, scrolls if longer)
- Track model: handle artist.name as {display:...} object for top_tracks format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 14:02:26 +01:00
joren
e37de6d897 fix: correct top_tracks field name and remove unused extra param
Qobuz API uses snake_case so the field is top_tracks, not topTracks.
Also remove the extra=topTracks query param — top tracks are included
in the default artist/page response without it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:58:51 +01:00
joren
5ae18afa08 feat: full artist release list via artist/getReleasesList
Instead of relying on the limited preview in artist/page, fire a
separate artist/getReleasesList request per release type (album,
epSingle, live, compilation) in parallel when loading an artist.
Each result updates its section independently as it arrives, so the
page populates progressively without a single large request.

Also fixes the artist name in the status bar (was reading wrong field).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:53:57 +01:00
joren
6f11b364aa feat: show top tracks on artist profile with play/shuffle
- Adds extra=topTracks to artist/page API request
- Embeds a List::Tracks widget at the top of ArtistView showing
  the artist's most popular tracks, with Play and Shuffle buttons
- Bubbles playTrackRequested through MainContent up to MainWindow
- Also adds the viz PCM ring buffer FFI infrastructure (for future
  spectrum widget) to the Rust backend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:45:19 +01:00
joren
4ba6d00748 fix: artistview nullptr connect warning + album version in header
- Move updateToggleText() after m_list creation to avoid null connects
- Show version (e.g. "Deluxe") in album header title

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 23:55:12 +01:00
joren
e4c2694584 chore: remove dead code to eliminate all compiler warnings
- Remove unused get_artist() (replaced by get_artist_page())
- Remove unused Format::label(), Format::all()
- Remove unused QwsTokenResponse and QwsToken structs
- Remove unused PlayerCommand::Seek (seeking uses atomics directly)
- Remove unused PlayerState::Stopped, TrackInfo::format
- Remove unused PlayerStatus::get_current_track()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 23:27:51 +01:00
joren
5d0011cb90 Merge fix/code-quality-audit into main 2026-03-24 23:19:09 +01:00
joren
5bda2396d1 fix: security hardening and code quality improvements
Build hardening:
- Add -fstack-protector-strong, -D_FORTIFY_SOURCE=2, PIE, full RELRO
- Enable overflow-checks in Rust release profile

Rust backend:
- Return null (not panic) if Tokio runtime or QobuzClient init fails
- Strip null bytes in FFI JSON callback to prevent CString panics
- Document MD5 and password-in-query as Qobuz API constraints

C++ frontend:
- Validate JSON document before accessing fields in onEvent()
- Handle null backend pointer from failed init
- Set biography label to PlainText and decode HTML entities to prevent
  rendering injected content from API responses
- Clamp slider position and guard negative durations
- Use qint64 for duration formatting to avoid int truncation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 23:19:04 +01:00
joren
eb5c151d3a Merge feat/multi-disc-artist-sections into main 2026-03-24 23:09:11 +01:00
joren
872fdecdce feat: artist/page endpoint, multi-disc fix, playlist ownership, UX improvements
- Switch artist view to artist/page API (proper sections: Albums, Singles & EPs,
  Live, Compilations; version in titles like "Deluxe")
- Fix artist sections categorization using releases[].type from artist/page
- Add getUser() backend call; fetch on session restore when userId=0 to fix
  playlist ownership (Remove from playlist / Delete playlist were missing)
- Fix multi-disc double-click / Play Now queue start index (disc headers were
  counted in row index but excluded from currentTracksJson)
- Hide redundant Album column when viewing an album
- Make artist name in context header clickable (navigates to artist page)
- Fix gap between title and artist name in context header (addStretch)
- Fix queue panel track titles to include version field
- Fix album list to show version in title column

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 23:09:04 +01:00
joren
69fb818c38 Merge feat/playlist-gapless-theme into main
Features and fixes from this branch:
- Album/playlist context header with art, metadata, Play & Shuffle buttons
- Playlist ownership filtering (delete, remove-from-playlist, add-to-playlist)
- Gapless toggle that actually works (controls audio output lifecycle)
- Resolution display in album header (e.g. 24-bit / 96 kHz)
- Now-playing art scales to dock width via ArtWidget (hasHeightForWidth)
- Skip unavailable tracks in queue; non-streamable tracks filtered out
- Artist profile: collapsible Albums / EPs & Singles / Other sections
- Favorites star indicator + context menu shows Add or Remove only
- Shuffle button does one-time shuffle without setting global flag
- Volume popup replaced with Qt::Popup QFrame (no lag, correct alignment)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:56:54 +01:00
joren
56473cae6f feat: artist sections, fav indicator, art scaling fix, volume popup fix
- Artist profile: collapsible Albums / EPs & Singles / Other sections
  keyed on release_type; fetches up to 200 albums per artist
- Favorites: starred icon on favorited tracks, context menu shows
  Add or Remove (not both); IDs cached when fav tracks are loaded
- Shuffle button: one-time shuffle via shuffleNow() without touching
  global shuffle flag, so double-click still plays in order
- Now-playing art: replaced setFixedHeight hack with ArtWidget that
  overrides hasHeightForWidth() — scales smoothly up and down, no min-size
- Volume popup: replaced QMenu (laggy, broken drag) with Qt::Popup QFrame;
  appears below button; fixed size locked at 100% label width

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:56:47 +01:00
joren
75429faffe feat: playlist art fix + Play/Shuffle buttons in context header
- Fix playlist art: API returns images/images150/images300 arrays, not
  image_rectangle; update Rust model and Qt header to use images300 →
  images150 → images with fallback
- Add Play (▶) and Shuffle (⇄) buttons to the album/playlist header;
  Play starts from track 1, Shuffle enables shuffle mode and plays from
  a randomised position

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:20:00 +01:00
joren
9327147021 fix: art scaling on shrink + skip unavailable tracks in queue
- Now-playing art: use setFixedHeight (not setFixedSize) so the dock
  width constraint is removed and can shrink freely; use event->size()
  in resizeEvent to get the correct new width without layout lag
- PlayQueue: filter non-streamable tracks out of setContext and
  reorderContext so unavailable tracks are never in the queue and
  pressing Next always lands on a playable track

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:15:49 +01:00
joren
9ca17b4406 feat: now-playing album art scales to full dock width
Stores the raw downloaded pixmap and rescales it (smooth, aspect-ratio
preserved) whenever the dock is resized, so the image always fills
the available width.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:46:43 +01:00
joren
7b0e5dcfa8 feat: show resolution in album header (e.g. 24-bit / 96 kHz)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:44:35 +01:00
joren
1e4c234b5c fix: gapless toggle now actually controls audio output lifecycle
When gapless is off, the AudioOutput is dropped after each track ends
naturally, producing a real gap on the next play. When on, the output
stays alive so tracks transition seamlessly. Also re-adds URL prefetch
gating behind the same toggle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:39:24 +01:00
joren
c035ce2dee fix: remove gapless toggle — architecture is always gapless
The player keeps the audio output alive between tracks unconditionally.
The toggle only controlled URL prefetching, not actual audio gaplessness.
Remove the setting and always prefetch the next track URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:36:54 +01:00
joren
8088412d4b feat: album/playlist header and playlist ownership filtering
- Add TrackContextHeader widget: shows album art (fetched via NAM),
  title, subtitle (artist/description), and metadata (year · tracks · duration)
  above the track list when an album or playlist is opened
- Hide header for favorite tracks and search results
- Store user ID in AppSettings on login
- Only show "Delete playlist" for playlists the user owns
- "Add to playlist" submenu only lists owned playlists
- "Remove from this playlist" only appears when viewing an owned playlist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:34:04 +01:00
joren
75e2b623b8 feat: refresh views on all playlist mutations
- Add EV_PLAYLIST_TRACK_ADDED (22) — emitted when a track is
  successfully added to a playlist
- If the currently-open playlist is the one modified, re-fetch it
  so the track appears in the list immediately
- After creating a playlist, open it automatically so the user
  lands in the new (empty) playlist view right away
- Sidebar already refreshes on create/delete; this ensures the
  track list view also stays in sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:23:45 +01:00
joren
f30e2058c1 feat: playlist management, gapless playback, ReplayGain, Qobuz theme
Playlist management:
- Add/remove tracks from playlists via right-click context menu
- Create new playlists (right-click Playlists sidebar header)
- Delete playlists with confirmation dialog (right-click playlist item)
- Playlist view removes track immediately on delete (optimistic)
- Deleting currently-open playlist clears the track view

Gapless playback:
- Single long-running audio thread owns AudioOutput; CPAL stream stays
  open between tracks eliminating device teardown/startup gap
- Decode runs inline on the audio thread; command channel polled via
  try_recv() so Pause/Resume/Seek/Stop/Play all work without spawning
- New Play command arriving mid-decode is handled immediately,
  reusing the same audio output for zero-gap transition
- Position timer reduced from 500 ms to 50 ms for faster track-end detection
- URL/metadata prefetch: when gapless is enabled Qt pre-fetches the next
  track while the current one is still playing

ReplayGain:
- Toggled in Settings → Playback
- replaygain_track_gain (dB) from track audio_info converted to linear
  gain factor and applied per-sample alongside volume

Qobuz dark theme:
- Background #191919, base #141414, accent #FFB232 (yellow-orange)
- Selection highlight, slider fill, scrollbar hover all use #FFB232
- Links use Qobuz blue #46B3EE
- Hi-res H badges updated to #FFB232 (from #FFD700)
- Now-playing row uses #FFB232 (was Spotify green)
- QSS stylesheet for scrollbars, menus, inputs, buttons, groups

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:19:32 +01:00
joren
c8086e1f2f fix: single-line queue items — Title · Artist on left, duration right
Compact single row: title in medium weight, separator dot and artist
name dimmed, duration right-aligned. Height scales with font.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:37:17 +01:00
joren
6f4029c4bb fix: compact queue item height based on font metrics
Height is now 2 line-heights + 10px padding instead of a fixed 52px,
keeping the two-line layout without wasting vertical space.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:34:34 +01:00
joren
d8619d9efe feat: custom queue item delegate with two-line layout
Each queue item now shows:
- Track title (medium weight) top-left
- Duration (small, dimmed) top-right
- Artist name (small, dimmed) bottom-left

Replaces the flat "Artist — Track" single-line text.
Play-next items get a slightly brighter title tint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:32:17 +01:00
joren
c4c3b2c19d feat: show track title and artist on separate lines in toolbar
Title shown in semi-bold, artist below in smaller grey text — matching
the previous two-line style the user preferred.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:29:31 +01:00
joren
15ba6f7a1e refactor: rewrite toolbar to match spotify-qt structure exactly
Flat QToolBar with sequential addAction/addWidget — no nested containers.
Order: [art][track] | [prev][play][next] [leftSpacer] [progress][time] [rightSpacer] [shuffle][vol][queue][search]

Centering via resizeEvent: both spacers get width/6 of total bar width,
mirroring the exact approach used in spotify-qt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:28:10 +01:00
joren
8950fd2914 fix: toolbar layout — controls inline with track info, progress bar centered
Left:   [art] [title/artist] [⏮] [⏯] [⏭]
Center: [0:00] [━━━━━━━━━━] [3:45]
Right:  [vol] [shuffle] [queue] [search]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:23:04 +01:00
joren
3182c0585c fix: move media controls next to track info, not centered
Controls (prev/play/next) and progress bar are now stacked vertically
to the right of the album art + track label, all in the left section.
Right side keeps volume, shuffle, queue, search toggles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:20:44 +01:00
joren
647054dab2 feat: spotify-style toolbar layout
Three-column layout matching spotify-qt:
- Left: album art thumbnail + track title/artist (stacked, 2 lines)
- Center: prev/play/next controls above elapsed / progress / total
- Right: volume button, shuffle, queue toggle, search toggle

Controls and toggles use QToolButton directly instead of QAction so the
layout is driven by HBoxLayout + VBoxLayout with equal stretch on left
and right, keeping the player controls perfectly centred at all widths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:16:59 +01:00
joren
373fc2b43c feat: queue panel skip-to-track and drag reorder; remove visualizer
Queue panel:
- Double-clicking an upcoming track skips to it immediately: drops all
  tracks before it from the queue and starts playback (skipToUpcoming)
- Items can be dragged to reorder; rowsMoved rebuilds the queue via
  setUpcomingOrder()
- Track JSON stored per-item so order survives drag operations
- New PlayQueue methods: skipToUpcoming(), setUpcomingOrder()
- New QueuePanel signal: skipToTrackRequested(qint64) wired to MainWindow

Remove visualizer:
- Drop VisualizerWidget, Qt6::OpenGLWidgets, projectM CMake detection
- Remove qobuz_backend_read_pcm FFI (Rust + C header + Qt wrapper)
- Remove pcm_visualizer from PlayerStatus and PCM tap from AudioOutput

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:12:30 +01:00
joren
74e43b9713 fix: store TypeRole in col 0 for search album results
When the H badge column was added, TypeRole was accidentally stored in
col 1 but the double-click handler reads it from col 0, breaking album
navigation from search. Move TypeRole back to col 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:12:22 +01:00
joren
7e2a04ecff fix: placeholder text color and H badge in search album results
- Add QPalette::PlaceholderText (#8C8C8C) to the dark palette so
  placeholder text in search box and settings fields is readable
- Add H column to search album results (same gold badge as album list view)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:25:42 +01:00
joren
4a9a7f9cbb feat: gold H badge on hi-res albums in album list view
Adds a narrow first column showing a small bold gold "H" for any album
with hires_streamable=true. Applies to both fav albums and artist pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:22:57 +01:00
joren
b9b47f80e7 feat: seeking support and Last.fm scrobbling
Seeking:
- Rust player: seek_requested/seek_target_secs atomics on PlayerStatus
- Decoder loop checks for seek each iteration, calls format.seek() and resets decoder
- New qobuz_backend_seek C FFI + QobuzBackend::seek(quint64)
- Progress slider onProgressReleased now seeks to the dragged position

Last.fm:
- LastFmScrobbler: now-playing + scrobble (50% or 240s threshold, min 30s)
- API signature follows Last.fm spec (sorted params, md5)
- Settings dialog: API key/secret, username/password, Connect button with status
- AppSettings: lastfm/enabled, api_key, api_secret, session_key
- Scrobbler wired to trackChanged, positionChanged, trackFinished in MainWindow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:13:06 +01:00
43 changed files with 3387 additions and 457 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,7 @@
build/
build-*/
target/
src/visualizer/
.cache/
*.user
*.autosave

View File

@@ -88,9 +88,18 @@ if (UNIX AND NOT APPLE)
target_link_libraries(qobuz-qt PRIVATE asound)
endif ()
# Compiler warnings
# Compiler warnings + hardening
if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID STREQUAL "Clang")
target_compile_options(qobuz-qt PRIVATE -Wall -Wextra -Wno-unused-parameter)
target_compile_options(qobuz-qt PRIVATE
-Wall -Wextra -Wno-unused-parameter
-fstack-protector-strong
-D_FORTIFY_SOURCE=2
-fPIE
)
target_link_options(qobuz-qt PRIVATE
-pie
-Wl,-z,relro,-z,now
)
endif ()
# D-Bus

View File

@@ -31,3 +31,4 @@ toml = "0.8"
[profile.release]
lto = "thin"
opt-level = 3
overflow-checks = true

View File

@@ -31,6 +31,12 @@ enum QobuzEvent {
EV_TRACK_URL_OK = 17,
EV_TRACK_URL_ERR = 18,
EV_GENERIC_ERR = 19,
EV_PLAYLIST_CREATED = 20,
EV_PLAYLIST_DELETED = 21,
EV_PLAYLIST_TRACK_ADDED = 22,
EV_USER_OK = 23,
EV_ARTIST_RELEASES_OK = 24,
EV_DEEP_SHUFFLE_OK = 25,
};
// Callback signature
@@ -43,6 +49,7 @@ void qobuz_backend_free(QobuzBackendOpaque *backend);
// Auth
void qobuz_backend_login(QobuzBackendOpaque *backend, const char *email, const char *password);
void qobuz_backend_set_token(QobuzBackendOpaque *backend, const char *token);
void qobuz_backend_get_user(QobuzBackendOpaque *backend);
// Catalog
void qobuz_backend_search(QobuzBackendOpaque *backend, const char *query, uint32_t offset, uint32_t limit);
@@ -62,17 +69,42 @@ void qobuz_backend_pause(QobuzBackendOpaque *backend);
void qobuz_backend_resume(QobuzBackendOpaque *backend);
void qobuz_backend_stop(QobuzBackendOpaque *backend);
void qobuz_backend_set_volume(QobuzBackendOpaque *backend, uint8_t volume);
void qobuz_backend_seek(QobuzBackendOpaque *backend, uint64_t position_secs);
uint64_t qobuz_backend_get_position(const QobuzBackendOpaque *backend);
uint64_t qobuz_backend_get_duration(const QobuzBackendOpaque *backend);
uint8_t qobuz_backend_get_volume(const QobuzBackendOpaque *backend);
int qobuz_backend_get_state(const QobuzBackendOpaque *backend);
int qobuz_backend_take_track_finished(QobuzBackendOpaque *backend);
// ReplayGain / Gapless
void qobuz_backend_set_replaygain(QobuzBackendOpaque *backend, bool enabled);
void qobuz_backend_set_gapless(QobuzBackendOpaque *backend, bool enabled);
void qobuz_backend_prefetch_track(QobuzBackendOpaque *backend, int64_t track_id, int32_t format_id);
// Visualizer PCM access
uint32_t qobuz_backend_viz_read(QobuzBackendOpaque *backend, float *buf, uint32_t max_samples);
uint32_t qobuz_backend_viz_sample_rate(const QobuzBackendOpaque *backend);
uint32_t qobuz_backend_viz_channels(const QobuzBackendOpaque *backend);
// Artist releases (auto-paginates to fetch all)
void qobuz_backend_get_artist_releases(QobuzBackendOpaque *backend, int64_t artist_id, const char *release_type, uint32_t limit, uint32_t offset);
// Deep shuffle: fetch tracks from multiple albums (album_ids_json is a JSON array of strings)
void qobuz_backend_get_albums_tracks(QobuzBackendOpaque *backend, const char *album_ids_json);
// Playlist management
void qobuz_backend_create_playlist(QobuzBackendOpaque *backend, const char *name);
void qobuz_backend_delete_playlist(QobuzBackendOpaque *backend, int64_t playlist_id);
void qobuz_backend_add_track_to_playlist(QobuzBackendOpaque *backend, int64_t playlist_id, int64_t track_id);
void qobuz_backend_delete_track_from_playlist(QobuzBackendOpaque *backend, int64_t playlist_id, int64_t playlist_track_id);
// Favorites modification
void qobuz_backend_add_fav_track(QobuzBackendOpaque *backend, int64_t track_id);
void qobuz_backend_remove_fav_track(QobuzBackendOpaque *backend, int64_t track_id);
void qobuz_backend_add_fav_album(QobuzBackendOpaque *backend, const char *album_id);
void qobuz_backend_remove_fav_album(QobuzBackendOpaque *backend, const char *album_id);
void qobuz_backend_add_fav_artist(QobuzBackendOpaque *backend, int64_t artist_id);
void qobuz_backend_remove_fav_artist(QobuzBackendOpaque *backend, int64_t artist_id);
#ifdef __cplusplus
}

View File

@@ -60,6 +60,8 @@ impl QobuzClient {
.as_secs()
}
/// Compute the request signature required by the Qobuz API.
/// NOTE: MD5 is mandated by the Qobuz API protocol — not our choice.
fn request_sig(&self, method: &str, params: &mut Vec<(&str, String)>, ts: u64) -> String {
params.sort_by_key(|(k, _)| *k);
let mut s = method.replace('/', "");
@@ -89,6 +91,15 @@ impl QobuzClient {
Ok(body)
}
fn post_request(&self, method: &str) -> reqwest::RequestBuilder {
let mut builder = self.http.post(self.url(method));
builder = builder.query(&[("app_id", self.app_id.as_str())]);
if let Some(token) = &self.auth_token {
builder = builder.header("Authorization", format!("Bearer {}", token));
}
builder
}
fn get_request(&self, method: &str) -> reqwest::RequestBuilder {
let mut builder = self.http.get(self.url(method));
builder = builder.query(&[("app_id", self.app_id.as_str())]);
@@ -107,6 +118,7 @@ impl QobuzClient {
}
}
/// NOTE: Qobuz API requires credentials as GET query params — not our choice.
async fn oauth2_login(&mut self, email: &str, password: &str) -> Result<OAuthLoginResponse> {
let ts = Self::ts();
let mut sign_params: Vec<(&str, String)> = vec![
@@ -243,17 +255,35 @@ impl QobuzClient {
// --- Artist ---
pub async fn get_artist(&self, artist_id: i64) -> Result<ArtistDto> {
pub async fn get_artist_page(&self, artist_id: i64) -> Result<Value> {
let resp = self
.get_request("artist/get")
.get_request("artist/page")
.query(&[("artist_id", artist_id.to_string())])
.send()
.await?;
Self::check_response(resp).await
}
pub async fn get_artist_releases_list(
&self,
artist_id: i64,
release_type: &str,
limit: u32,
offset: u32,
) -> Result<Value> {
let resp = self
.get_request("artist/getReleasesList")
.query(&[
("artist_id", artist_id.to_string()),
("extra", "albums".to_string()),
("release_type", release_type.to_string()),
("sort", "release_date".to_string()),
("order", "desc".to_string()),
("limit", limit.to_string()),
("offset", offset.to_string()),
])
.send()
.await?;
let body = Self::check_response(resp).await?;
Ok(serde_json::from_value(body)?)
Self::check_response(resp).await
}
// --- Search ---
@@ -329,6 +359,55 @@ impl QobuzClient {
Ok(serde_json::from_value(body["artists"].clone())?)
}
// --- Playlist management ---
pub async fn create_playlist(&self, name: &str) -> Result<PlaylistDto> {
let resp = self
.post_request("playlist/create")
.form(&[("name", name), ("is_public", "false"), ("is_collaborative", "false")])
.send()
.await?;
let body = Self::check_response(resp).await?;
Ok(serde_json::from_value(body)?)
}
pub async fn add_track_to_playlist(&self, playlist_id: i64, track_id: i64) -> Result<()> {
let resp = self
.post_request("playlist/addTracks")
.form(&[
("playlist_id", playlist_id.to_string()),
("track_ids", track_id.to_string()),
("no_duplicate", "true".to_string()),
])
.send()
.await?;
Self::check_response(resp).await?;
Ok(())
}
pub async fn delete_playlist(&self, playlist_id: i64) -> Result<()> {
let resp = self
.get_request("playlist/delete")
.query(&[("playlist_id", &playlist_id.to_string())])
.send()
.await?;
Self::check_response(resp).await?;
Ok(())
}
pub async fn delete_track_from_playlist(&self, playlist_id: i64, playlist_track_id: i64) -> Result<()> {
let resp = self
.post_request("playlist/deleteTracks")
.form(&[
("playlist_id", playlist_id.to_string()),
("playlist_track_ids", playlist_track_id.to_string()),
])
.send()
.await?;
Self::check_response(resp).await?;
Ok(())
}
pub async fn add_fav_track(&self, track_id: i64) -> Result<()> {
let resp = self
.get_request("favorite/create")
@@ -368,4 +447,24 @@ impl QobuzClient {
Self::check_response(resp).await?;
Ok(())
}
pub async fn add_fav_artist(&self, artist_id: i64) -> Result<()> {
let resp = self
.get_request("favorite/create")
.query(&[("type", "artists"), ("artist_ids", &artist_id.to_string())])
.send()
.await?;
Self::check_response(resp).await?;
Ok(())
}
pub async fn remove_fav_artist(&self, artist_id: i64) -> Result<()> {
let resp = self
.get_request("favorite/delete")
.query(&[("type", "artists"), ("artist_ids", &artist_id.to_string())])
.send()
.await?;
Self::check_response(resp).await?;
Ok(())
}
}

View File

@@ -46,8 +46,10 @@ pub struct SubscriptionDto {
pub struct TrackDto {
pub id: i64,
pub title: Option<String>,
pub version: Option<String>,
pub duration: Option<i64>,
pub track_number: Option<i32>,
pub playlist_track_id: Option<i64>,
pub album: Option<AlbumDto>,
pub performer: Option<ArtistDto>,
pub composer: Option<ArtistDto>,
@@ -97,6 +99,7 @@ pub struct AlbumDto {
pub maximum_sampling_rate: Option<f64>,
pub hires_streamable: Option<bool>,
pub streamable: Option<bool>,
pub release_type: Option<String>,
pub tracks: Option<TracksWrapper>,
}
@@ -118,6 +121,10 @@ pub struct ArtistDto {
pub image: Option<ImageDto>,
pub biography: Option<BiographyDto>,
pub albums: Option<SearchResultItems<AlbumDto>>,
#[serde(rename = "epSingles")]
pub ep_singles: Option<SearchResultItems<AlbumDto>>,
#[serde(rename = "liveAlbums")]
pub live_albums: Option<SearchResultItems<AlbumDto>>,
}
#[derive(Debug, Deserialize, Clone, Serialize)]
@@ -182,7 +189,12 @@ pub struct PlaylistDto {
pub duration: Option<i64>,
pub description: Option<String>,
pub owner: Option<PlaylistOwnerDto>,
pub image_rectangle: Option<Vec<String>>,
/// 4-cover mosaic at 300 px (preferred)
pub images300: Option<Vec<String>>,
/// 4-cover mosaic at 150 px (fallback)
pub images150: Option<Vec<String>>,
/// 4-cover mosaic at 50 px (last resort)
pub images: Option<Vec<String>>,
pub tracks: Option<TracksWrapper>,
}
@@ -232,30 +244,5 @@ impl Format {
}
}
pub fn label(self) -> &'static str {
match self {
Format::Mp3 => "MP3 320",
Format::Cd => "CD 16-bit",
Format::HiRes96 => "Hi-Res 24-bit/96kHz",
Format::HiRes192 => "Hi-Res 24-bit/192kHz",
}
}
pub fn all() -> &'static [Format] {
&[Format::HiRes192, Format::HiRes96, Format::Cd, Format::Mp3]
}
}
// --- QWS ---
#[derive(Debug, Deserialize, Clone, Serialize)]
pub struct QwsTokenResponse {
pub jwt_qws: Option<QwsToken>,
}
#[derive(Debug, Deserialize, Clone, Serialize)]
pub struct QwsToken {
pub exp: Option<i64>,
pub jwt: Option<String>,
pub endpoint: Option<String>,
}

View File

@@ -68,6 +68,8 @@ pub const EV_POSITION: c_int = 16;
pub const EV_TRACK_URL_OK: c_int = 17;
pub const EV_TRACK_URL_ERR: c_int = 18;
pub const EV_GENERIC_ERR: c_int = 19;
pub const EV_ARTIST_RELEASES_OK: c_int = 24;
pub const EV_DEEP_SHUFFLE_OK: c_int = 25;
// ---------- Callback ----------
@@ -75,12 +77,20 @@ pub type EventCallback = unsafe extern "C" fn(*mut c_void, c_int, *const c_char)
// ---------- Backend ----------
struct PrefetchedTrack {
track_id: i64,
track: api::models::TrackDto,
url: String,
}
struct BackendInner {
client: Arc<Mutex<QobuzClient>>,
player: Player,
rt: Runtime,
cb: EventCallback,
ud: SendPtr,
replaygain_enabled: std::sync::Arc<std::sync::atomic::AtomicBool>,
prefetch: std::sync::Arc<tokio::sync::Mutex<Option<PrefetchedTrack>>>,
}
pub struct Backend(BackendInner);
@@ -88,7 +98,9 @@ pub struct Backend(BackendInner);
// ---------- Helpers ----------
fn call_cb(cb: EventCallback, ud: SendPtr, ev: c_int, json: &str) {
let cstr = CString::new(json).unwrap_or_else(|_| CString::new("{}").unwrap());
// Strip null bytes that would cause CString::new to fail
let safe = json.replace('\0', "");
let cstr = CString::new(safe).unwrap_or_else(|_| CString::new("{}").unwrap());
unsafe { cb(ud.0, ev, cstr.as_ptr()) };
}
@@ -111,8 +123,14 @@ pub unsafe extern "C" fn qobuz_backend_new(
event_cb: EventCallback,
userdata: *mut c_void,
) -> *mut Backend {
let rt = Runtime::new().expect("tokio runtime");
let client = Arc::new(Mutex::new(QobuzClient::new().expect("QobuzClient")));
let rt = match Runtime::new() {
Ok(r) => r,
Err(_) => return std::ptr::null_mut(),
};
let client = match QobuzClient::new() {
Ok(c) => Arc::new(Mutex::new(c)),
Err(_) => return std::ptr::null_mut(),
};
let player = Player::new();
Box::into_raw(Box::new(Backend(BackendInner {
@@ -121,6 +139,8 @@ pub unsafe extern "C" fn qobuz_backend_new(
rt,
cb: event_cb,
ud: SendPtr(userdata),
replaygain_enabled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
prefetch: std::sync::Arc::new(tokio::sync::Mutex::new(None)),
})))
}
@@ -170,11 +190,9 @@ pub unsafe extern "C" fn qobuz_backend_login(
pub unsafe extern "C" fn qobuz_backend_set_token(ptr: *mut Backend, token: *const c_char) {
let inner = &(*ptr).0;
let token = CStr::from_ptr(token).to_string_lossy().into_owned();
let client = inner.client.clone();
// blocking_lock is available on tokio::sync::Mutex when not in an async context
inner.rt.spawn(async move {
client.lock().await.set_auth_token(token);
});
// Use blocking_lock (called from Qt main thread, not a tokio thread) so the
// token is set before any subsequent getUser/library requests are spawned.
inner.client.blocking_lock().set_auth_token(token);
}
// ---------- Search ----------
@@ -229,7 +247,7 @@ pub unsafe extern "C" fn qobuz_backend_get_artist(ptr: *mut Backend, artist_id:
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
let result = client.lock().await.get_artist(artist_id).await;
let result = client.lock().await.get_artist_page(artist_id).await;
let (ev, json) = match result {
Ok(r) => (EV_ARTIST_OK, serde_json::to_string(&r).unwrap_or_default()),
Err(e) => (EV_ARTIST_ERR, err_json(&e.to_string())),
@@ -238,6 +256,109 @@ pub unsafe extern "C" fn qobuz_backend_get_artist(ptr: *mut Backend, artist_id:
});
}
// ---------- Artist releases ----------
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_artist_releases(
ptr: *mut Backend,
artist_id: i64,
release_type: *const c_char,
limit: u32,
_offset: u32,
) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
let rtype = CStr::from_ptr(release_type).to_string_lossy().into_owned();
spawn(inner, async move {
// Auto-paginate: fetch all pages until has_more is false.
let mut all_items: Vec<serde_json::Value> = Vec::new();
let mut offset: u32 = 0;
loop {
let result = client.lock().await
.get_artist_releases_list(artist_id, &rtype, limit, offset)
.await;
match result {
Ok(r) => {
let obj = r.as_object().cloned().unwrap_or_default();
if let Some(items) = obj.get("items").and_then(|v| v.as_array()) {
all_items.extend(items.iter().cloned());
}
let has_more = obj.get("has_more").and_then(|v| v.as_bool()).unwrap_or(false);
if !has_more {
break;
}
offset += limit;
}
Err(e) => {
call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string()));
return;
}
}
}
let result = serde_json::json!({
"release_type": rtype,
"items": all_items,
"has_more": false,
"offset": 0
});
call_cb(cb, ud, EV_ARTIST_RELEASES_OK, &serde_json::to_string(&result).unwrap_or_default());
});
}
// ---------- Deep shuffle (fetch tracks from multiple albums) ----------
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_albums_tracks(
ptr: *mut Backend,
album_ids_json: *const c_char,
) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
let ids_str = CStr::from_ptr(album_ids_json).to_string_lossy().into_owned();
let album_ids: Vec<String> = match serde_json::from_str(&ids_str) {
Ok(v) => v,
Err(e) => {
call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string()));
return;
}
};
spawn(inner, async move {
let mut all_tracks: Vec<serde_json::Value> = Vec::new();
for id in &album_ids {
let result = client.lock().await.get_album(id).await;
if let Ok(album) = result {
if let Some(tracks) = album.tracks.as_ref().and_then(|t| t.items.as_ref()) {
for t in tracks {
// Serialize track and inject album info for playback context
if let Ok(mut tv) = serde_json::to_value(t) {
if let Some(obj) = tv.as_object_mut() {
// Ensure album context is present on each track
if obj.get("album").is_none() || obj["album"].is_null() {
obj.insert("album".to_string(), serde_json::json!({
"id": album.id,
"title": album.title,
"artist": album.artist,
"image": album.image,
}));
}
}
all_tracks.push(tv);
}
}
}
}
// Skip albums that fail — don't abort the whole operation
}
let result = serde_json::json!({ "tracks": all_tracks });
call_cb(cb, ud, EV_DEEP_SHUFFLE_OK, &serde_json::to_string(&result).unwrap_or_default());
});
}
// ---------- Playlist ----------
#[no_mangle]
@@ -341,15 +462,29 @@ pub unsafe extern "C" fn qobuz_backend_play_track(
let format = Format::from_id(format_id);
let cmd_tx = inner.player.cmd_tx.clone();
let status = inner.player.status.clone();
let prefetch = inner.prefetch.clone();
let rg_enabled = inner.replaygain_enabled.clone();
spawn(inner, async move {
// 1. Track metadata
// 1. Check prefetch cache first for zero-gap start
let cached = {
let mut lock = prefetch.lock().await;
if lock.as_ref().map(|p| p.track_id == track_id).unwrap_or(false) {
lock.take()
} else {
None
}
};
let (track, url) = if let Some(pf) = cached {
(pf.track, pf.url)
} else {
// Fetch track metadata
let track = match client.lock().await.get_track(track_id).await {
Ok(t) => t,
Err(e) => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json(&e.to_string())); return; }
};
// 2. Stream URL
// Fetch stream URL
let url_dto = match client.lock().await.get_track_url(track_id, format).await {
Ok(u) => u,
Err(e) => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json(&e.to_string())); return; }
@@ -358,18 +493,27 @@ pub unsafe extern "C" fn qobuz_backend_play_track(
Some(u) => u,
None => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json("no stream URL")); return; }
};
(track, url)
};
// 3. Notify track change
// 2. Notify track change
if let Ok(j) = serde_json::to_string(&track) {
call_cb(cb, ud, EV_TRACK_CHANGED, &j);
}
// 3. Compute ReplayGain if enabled
let replaygain_db = if rg_enabled.load(std::sync::atomic::Ordering::Relaxed) {
track.audio_info.as_ref().and_then(|ai| ai.replaygain_track_gain)
} else {
None
};
// 4. Update status + send play command
*status.current_track.lock().unwrap() = Some(track.clone());
if let Some(dur) = track.duration {
status.duration_secs.store(dur as u64, std::sync::atomic::Ordering::Relaxed);
}
let _ = cmd_tx.send(player::PlayerCommand::Play(player::TrackInfo { track, url, format }));
let _ = cmd_tx.send(player::PlayerCommand::Play(player::TrackInfo { track, url, replaygain_db }));
// 5. State notification
call_cb(cb, ud, EV_STATE_CHANGED, r#"{"state":"playing"}"#);
@@ -402,6 +546,11 @@ pub unsafe extern "C" fn qobuz_backend_set_volume(ptr: *mut Backend, volume: u8)
(*ptr).0.player.set_volume(volume);
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_seek(ptr: *mut Backend, position_secs: u64) {
(*ptr).0.player.seek(position_secs);
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_position(ptr: *const Backend) -> u64 {
(*ptr).0.player.status.get_position()
@@ -433,6 +582,46 @@ pub unsafe extern "C" fn qobuz_backend_take_track_finished(ptr: *mut Backend) ->
if finished { 1 } else { 0 }
}
// ---------- ReplayGain / Gapless ----------
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_set_replaygain(ptr: *mut Backend, enabled: bool) {
(*ptr).0.replaygain_enabled.store(enabled, std::sync::atomic::Ordering::Relaxed);
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_set_gapless(ptr: *mut Backend, enabled: bool) {
(*ptr).0.player.status.gapless.store(enabled, std::sync::atomic::Ordering::Relaxed);
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_prefetch_track(
ptr: *mut Backend,
track_id: i64,
format_id: i32,
) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let prefetch = inner.prefetch.clone();
let format = Format::from_id(format_id);
spawn(inner, async move {
let track = match client.lock().await.get_track(track_id).await {
Ok(t) => t,
Err(_) => return,
};
let url_dto = match client.lock().await.get_track_url(track_id, format).await {
Ok(u) => u,
Err(_) => return,
};
let url = match url_dto.url {
Some(u) => u,
None => return,
};
*prefetch.lock().await = Some(PrefetchedTrack { track_id, track, url });
});
}
// ---------- Favorites modification ----------
#[no_mangle]
@@ -484,3 +673,142 @@ pub unsafe extern "C" fn qobuz_backend_remove_fav_album(ptr: *mut Backend, album
}
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_add_fav_artist(ptr: *mut Backend, artist_id: i64) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
if let Err(e) = client.lock().await.add_fav_artist(artist_id).await {
call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string()));
}
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_remove_fav_artist(ptr: *mut Backend, artist_id: i64) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
if let Err(e) = client.lock().await.remove_fav_artist(artist_id).await {
call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string()));
}
});
}
// ---------- User ----------
pub const EV_USER_OK: c_int = 23;
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_user(ptr: *mut Backend) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
let result = client.lock().await.get_user().await;
let (ev, json) = match result {
Ok(r) => (EV_USER_OK, serde_json::to_string(&r).unwrap_or_default()),
Err(e) => (EV_GENERIC_ERR, err_json(&e.to_string())),
};
call_cb(cb, ud, ev, &json);
});
}
// ---------- Visualizer PCM access ----------
/// Read up to `max_samples` f32 PCM values into `buf`.
/// Returns the number of samples actually read.
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_viz_read(
ptr: *mut Backend,
buf: *mut f32,
max_samples: u32,
) -> u32 {
let consumer = &(*ptr).0.player.status.viz_consumer;
let Ok(mut lock) = consumer.try_lock() else { return 0 };
let slice = std::slice::from_raw_parts_mut(buf, max_samples as usize);
rb::RbConsumer::read(&mut *lock, slice).unwrap_or(0) as u32
}
/// Returns current sample rate of the audio stream (0 if idle).
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_viz_sample_rate(ptr: *const Backend) -> u32 {
(*ptr).0.player.status.viz_sample_rate.load(std::sync::atomic::Ordering::Relaxed)
}
/// Returns current channel count (0 if idle).
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_viz_channels(ptr: *const Backend) -> u32 {
(*ptr).0.player.status.viz_channels.load(std::sync::atomic::Ordering::Relaxed)
}
// ---------- Playlist management ----------
pub const EV_PLAYLIST_CREATED: c_int = 20;
pub const EV_PLAYLIST_DELETED: c_int = 21;
pub const EV_PLAYLIST_TRACK_ADDED: c_int = 22;
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_create_playlist(ptr: *mut Backend, name: *const c_char) {
let inner = &(*ptr).0;
let name = CStr::from_ptr(name).to_string_lossy().into_owned();
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
match client.lock().await.create_playlist(&name).await {
Ok(p) => call_cb(cb, ud, EV_PLAYLIST_CREATED, &serde_json::to_string(&p).unwrap_or_default()),
Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())),
}
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_delete_playlist(ptr: *mut Backend, playlist_id: i64) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
match client.lock().await.delete_playlist(playlist_id).await {
Ok(()) => call_cb(cb, ud, EV_PLAYLIST_DELETED,
&serde_json::json!({"playlist_id": playlist_id}).to_string()),
Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())),
}
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_add_track_to_playlist(
ptr: *mut Backend,
playlist_id: i64,
track_id: i64,
) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
match client.lock().await.add_track_to_playlist(playlist_id, track_id).await {
Ok(()) => call_cb(cb, ud, EV_PLAYLIST_TRACK_ADDED,
&serde_json::json!({"playlist_id": playlist_id}).to_string()),
Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())),
}
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_delete_track_from_playlist(
ptr: *mut Backend,
playlist_id: i64,
playlist_track_id: i64,
) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
if let Err(e) = client.lock().await.delete_track_from_playlist(playlist_id, playlist_track_id).await {
call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string()));
}
});
}

View File

@@ -1,4 +1,5 @@
use anyhow::Result;
use rb::RB;
use std::io::{self, Read, Seek, SeekFrom};
use std::sync::{
atomic::{AtomicBool, Ordering},
@@ -8,13 +9,14 @@ use std::sync::{
use symphonia::core::{
codecs::DecoderOptions,
errors::Error as SymphoniaError,
formats::FormatOptions,
formats::{FormatOptions, SeekMode, SeekTo},
io::{MediaSource, MediaSourceStream},
meta::MetadataOptions,
probe::Hint,
units::Time,
};
use crate::player::{output::AudioOutput, PlayerStatus};
use super::{output::AudioOutput, PlayerCommand, PlayerStatus, TrackInfo};
/// First 512 KiB of stream kept in memory to support backward seeks during probing.
const HEAD_SIZE: usize = 512 * 1024;
@@ -126,13 +128,22 @@ impl MediaSource for HttpStreamSource {
}
}
/// Stream and decode audio from `url`. Runs on a dedicated OS thread.
pub fn play_track(
/// Decode and play `url` inline on the calling thread (the player loop).
///
/// `audio_output` is reused across calls if the sample rate and channel count match,
/// keeping the CPAL stream open between tracks for gapless playback.
///
/// Returns:
/// - `Ok(Some(TrackInfo))` — a new Play command arrived; start that track next.
/// - `Ok(None)` — track finished naturally or was stopped.
/// - `Err(_)` — unrecoverable playback error.
pub fn play_track_inline(
url: &str,
status: &PlayerStatus,
stop: &Arc<AtomicBool>,
paused: &Arc<AtomicBool>,
) -> Result<()> {
audio_output: &mut Option<AudioOutput>,
cmd_rx: &std::sync::mpsc::Receiver<PlayerCommand>,
) -> Result<Option<TrackInfo>> {
let response = reqwest::blocking::get(url)?;
let content_length = response.content_length();
let source = HttpStreamSource::new(response, content_length);
@@ -159,23 +170,111 @@ pub fn play_track(
.make(&track.codec_params, &DecoderOptions::default())
.map_err(|e| anyhow::anyhow!("decoder init failed: {e}"))?;
let mut audio_output = AudioOutput::try_open(sample_rate, channels)?;
// Reuse existing audio output if format matches; rebuild only on format change.
if let Some(ao) = audio_output.as_ref() {
if ao.sample_rate != sample_rate || ao.channels != channels {
*audio_output = None; // will be recreated below
}
}
if audio_output.is_none() {
let mut ao = AudioOutput::try_open(sample_rate, channels)?;
ao.set_viz_producer(status.viz_ring.producer());
*audio_output = Some(ao);
}
status.viz_sample_rate.store(sample_rate, Ordering::Relaxed);
status.viz_channels.store(channels as u32, Ordering::Relaxed);
let ao = audio_output.as_mut().unwrap();
let mut stopped = false;
let mut next_track: Option<TrackInfo> = None;
'decode: loop {
// Non-blocking command check — handle Pause/Resume/Seek/Stop/Play
loop {
if stop.load(Ordering::SeqCst) {
match cmd_rx.try_recv() {
Ok(PlayerCommand::Pause) => {
paused.store(true, Ordering::SeqCst);
*status.state.lock().unwrap() = super::PlayerState::Paused;
}
Ok(PlayerCommand::Resume) => {
paused.store(false, Ordering::SeqCst);
*status.state.lock().unwrap() = super::PlayerState::Playing;
}
Ok(PlayerCommand::SetVolume(v)) => {
status.volume.store(v, Ordering::Relaxed);
}
Ok(PlayerCommand::Stop) => {
paused.store(false, Ordering::SeqCst);
*status.state.lock().unwrap() = super::PlayerState::Idle;
*status.current_track.lock().unwrap() = None;
status.position_secs.store(0, Ordering::Relaxed);
status.duration_secs.store(0, Ordering::Relaxed);
stopped = true;
break 'decode;
}
Ok(PlayerCommand::Play(info)) => {
// New track requested — stop current and return it
next_track = Some(info);
break 'decode;
}
Err(std::sync::mpsc::TryRecvError::Empty) => break,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
stopped = true;
break 'decode;
}
}
}
// Spin while paused, but keep checking for commands
while paused.load(Ordering::SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(10));
// Still check for Stop/Play while paused
match cmd_rx.try_recv() {
Ok(PlayerCommand::Resume) => {
paused.store(false, Ordering::SeqCst);
*status.state.lock().unwrap() = super::PlayerState::Playing;
}
Ok(PlayerCommand::Stop) => {
paused.store(false, Ordering::SeqCst);
stopped = true;
break;
}
while paused.load(Ordering::SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(50));
if stop.load(Ordering::SeqCst) {
return Ok(());
Ok(PlayerCommand::Play(info)) => {
paused.store(false, Ordering::SeqCst);
next_track = Some(info);
break 'decode;
}
Ok(PlayerCommand::SetVolume(v)) => {
status.volume.store(v, Ordering::Relaxed);
}
_ => {}
}
if stopped { break 'decode; }
}
if stopped { break; }
// Handle seek
if status.seek_requested.load(Ordering::SeqCst) {
status.seek_requested.store(false, Ordering::SeqCst);
let target = status.seek_target_secs.load(Ordering::Relaxed);
let seeked = format.seek(
SeekMode::Coarse,
SeekTo::Time { time: Time::from(target), track_id: None },
);
if let Ok(s) = seeked {
let actual = s.actual_ts / sample_rate as u64;
status.position_secs.store(actual, Ordering::Relaxed);
}
decoder.reset();
continue;
}
let packet = match format.next_packet() {
Ok(p) => p,
Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
break;
Err(SymphoniaError::IoError(e))
if e.kind() == std::io::ErrorKind::UnexpectedEof =>
{
break; // natural end of track
}
Err(SymphoniaError::ResetRequired) => {
decoder.reset();
@@ -189,13 +288,16 @@ pub fn play_track(
}
if let Some(ts) = packet.ts().checked_div(sample_rate as u64) {
status.position_secs.store(ts, std::sync::atomic::Ordering::Relaxed);
status.position_secs.store(ts, Ordering::Relaxed);
}
match decoder.decode(&packet) {
Ok(decoded) => {
let volume = status.volume.load(Ordering::Relaxed) as f32 / 100.0;
audio_output.write(decoded, volume, stop)?;
let rg = *status.replaygain_gain.lock().unwrap();
// Use a stop flag tied to new-track-incoming so write doesn't block
let dummy_stop = Arc::new(AtomicBool::new(false));
ao.write(decoded, (volume * rg).min(1.0), &dummy_stop)?;
}
Err(SymphoniaError::IoError(_)) => break,
Err(SymphoniaError::DecodeError(e)) => eprintln!("decode error: {e}"),
@@ -203,5 +305,10 @@ pub fn play_track(
}
}
Ok(())
if stopped {
// On explicit stop, drop the audio output to silence immediately
*audio_output = None;
}
Ok(next_track)
}

View File

@@ -1,13 +1,17 @@
mod decoder;
pub mod output;
use rb::{SpscRb, RB};
use std::sync::{
atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering},
atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicU8, Ordering},
Arc,
};
use std::time::Duration;
use crate::api::{Format, TrackDto};
use crate::api::TrackDto;
/// Size of the visualizer ring buffer in f32 samples (~180ms at 44.1kHz stereo).
const VIZ_RING_SIZE: usize = 16 * 1024;
#[derive(Debug, Clone)]
pub enum PlayerCommand {
@@ -15,7 +19,6 @@ pub enum PlayerCommand {
Pause,
Resume,
Stop,
Seek(u64),
SetVolume(u8),
}
@@ -23,7 +26,8 @@ pub enum PlayerCommand {
pub struct TrackInfo {
pub track: TrackDto,
pub url: String,
pub format: Format,
/// ReplayGain track gain in dB, if enabled and available.
pub replaygain_db: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
@@ -31,7 +35,6 @@ pub enum PlayerState {
Idle,
Playing,
Paused,
Stopped,
Error(String),
}
@@ -44,10 +47,24 @@ pub struct PlayerStatus {
pub current_track: Arc<std::sync::Mutex<Option<TrackDto>>>,
/// Set to true by the decode thread when a track finishes naturally.
pub track_finished: Arc<AtomicBool>,
/// Set by the player loop when a seek command arrives; cleared by the decode thread.
pub seek_requested: Arc<AtomicBool>,
pub seek_target_secs: Arc<AtomicU64>,
/// Linear gain factor to apply (1.0 = unity). Updated each time a new track starts.
pub replaygain_gain: Arc<std::sync::Mutex<f32>>,
/// When false the audio output is torn down after each track, producing a gap.
pub gapless: Arc<AtomicBool>,
/// Visualizer ring buffer (consumer side, read by FFI).
pub viz_ring: Arc<SpscRb<f32>>,
pub viz_consumer: Arc<std::sync::Mutex<rb::Consumer<f32>>>,
pub viz_sample_rate: Arc<AtomicU32>,
pub viz_channels: Arc<AtomicU32>,
}
impl PlayerStatus {
pub fn new() -> Self {
let viz_ring = Arc::new(SpscRb::new(VIZ_RING_SIZE));
let viz_consumer = Arc::new(std::sync::Mutex::new(viz_ring.consumer()));
Self {
state: Arc::new(std::sync::Mutex::new(PlayerState::Idle)),
position_secs: Arc::new(AtomicU64::new(0)),
@@ -55,6 +72,14 @@ impl PlayerStatus {
volume: Arc::new(AtomicU8::new(80)),
current_track: Arc::new(std::sync::Mutex::new(None)),
track_finished: Arc::new(AtomicBool::new(false)),
seek_requested: Arc::new(AtomicBool::new(false)),
seek_target_secs: Arc::new(AtomicU64::new(0)),
replaygain_gain: Arc::new(std::sync::Mutex::new(1.0)),
gapless: Arc::new(AtomicBool::new(false)),
viz_ring,
viz_consumer,
viz_sample_rate: Arc::new(AtomicU32::new(0)),
viz_channels: Arc::new(AtomicU32::new(0)),
}
}
@@ -74,9 +99,6 @@ impl PlayerStatus {
self.volume.load(Ordering::Relaxed)
}
pub fn get_current_track(&self) -> Option<TrackDto> {
self.current_track.lock().unwrap().clone()
}
}
pub struct Player {
@@ -101,10 +123,6 @@ impl Player {
self.cmd_tx.send(cmd).ok();
}
pub fn play_track(&self, track: TrackDto, url: String, format: Format) {
self.send(PlayerCommand::Play(TrackInfo { track, url, format }));
}
pub fn pause(&self) {
self.send(PlayerCommand::Pause);
}
@@ -121,19 +139,57 @@ impl Player {
self.status.volume.store(vol, Ordering::Relaxed);
self.send(PlayerCommand::SetVolume(vol));
}
pub fn seek(&self, secs: u64) {
self.status.seek_target_secs.store(secs, Ordering::Relaxed);
self.status.seek_requested.store(true, Ordering::SeqCst);
}
}
/// The player loop runs on a single dedicated OS thread.
/// It owns the `AudioOutput` locally so there are no Send constraints.
/// Decoding is performed inline; the command channel is polled via try_recv
/// inside the decode loop to handle Pause/Resume/Seek/Stop/Play without
/// tearng down and re-opening the audio device between tracks.
fn player_loop(rx: std::sync::mpsc::Receiver<PlayerCommand>, status: PlayerStatus) {
let mut stop_flag = Arc::new(AtomicBool::new(true));
let paused = Arc::new(AtomicBool::new(false));
use std::sync::mpsc::RecvTimeoutError;
let mut audio_output: Option<output::AudioOutput> = None;
let paused = Arc::new(AtomicBool::new(false));
// pending_info holds a Play command that interrupted an ongoing decode
let mut pending_info: Option<TrackInfo> = None;
'outer: loop {
// Wait for a Play command (or use one that was interrupted)
let info = if let Some(p) = pending_info.take() {
p
} else {
loop {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(cmd) => match cmd {
PlayerCommand::Play(info) => {
stop_flag.store(true, Ordering::SeqCst);
stop_flag = Arc::new(AtomicBool::new(false));
Ok(PlayerCommand::Play(info)) => break info,
Ok(PlayerCommand::Stop) => {
audio_output = None;
paused.store(false, Ordering::SeqCst);
*status.state.lock().unwrap() = PlayerState::Idle;
*status.current_track.lock().unwrap() = None;
status.position_secs.store(0, Ordering::Relaxed);
status.duration_secs.store(0, Ordering::Relaxed);
}
Ok(PlayerCommand::SetVolume(v)) => {
status.volume.store(v, Ordering::Relaxed);
}
Ok(_) => {} // Pause/Resume ignored when idle
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => break 'outer,
}
}
};
// Compute ReplayGain factor
let rg_factor = info.replaygain_db
.map(|db| 10f32.powf(db as f32 / 20.0))
.unwrap_or(1.0);
*status.replaygain_gain.lock().unwrap() = rg_factor;
*status.state.lock().unwrap() = PlayerState::Playing;
*status.current_track.lock().unwrap() = Some(info.track.clone());
@@ -141,46 +197,28 @@ fn player_loop(rx: std::sync::mpsc::Receiver<PlayerCommand>, status: PlayerStatu
status.duration_secs.store(dur as u64, Ordering::Relaxed);
}
status.position_secs.store(0, Ordering::Relaxed);
paused.store(false, Ordering::SeqCst);
let status_c = status.clone();
let stop_c = stop_flag.clone();
let paused_c = paused.clone();
std::thread::spawn(move || {
match decoder::play_track(&info.url, &status_c, &stop_c, &paused_c) {
Ok(()) => {
if !stop_c.load(Ordering::SeqCst) {
*status_c.state.lock().unwrap() = PlayerState::Idle;
status_c.track_finished.store(true, Ordering::SeqCst);
match decoder::play_track_inline(&info.url, &status, &paused, &mut audio_output, &rx) {
Ok(Some(next_info)) => {
// Interrupted by a new Play — loop immediately with reused audio output
pending_info = Some(next_info);
}
Ok(None) => {
// Track finished naturally — tear down audio output if gapless is off
if !status.gapless.load(Ordering::Relaxed) {
audio_output = None;
}
*status.state.lock().unwrap() = PlayerState::Idle;
status.track_finished.store(true, Ordering::SeqCst);
}
Err(e) => {
eprintln!("playback error: {e}");
*status_c.state.lock().unwrap() =
PlayerState::Error(e.to_string());
*status.state.lock().unwrap() = PlayerState::Error(e.to_string());
// Signal track end so the queue advances to the next track
// instead of stalling on an unplayable track.
status.track_finished.store(true, Ordering::SeqCst);
}
}
});
}
PlayerCommand::Pause => {
paused.store(true, Ordering::SeqCst);
*status.state.lock().unwrap() = PlayerState::Paused;
}
PlayerCommand::Resume => {
paused.store(false, Ordering::SeqCst);
*status.state.lock().unwrap() = PlayerState::Playing;
}
PlayerCommand::Stop => {
stop_flag.store(true, Ordering::SeqCst);
*status.state.lock().unwrap() = PlayerState::Idle;
*status.current_track.lock().unwrap() = None;
status.position_secs.store(0, Ordering::Relaxed);
status.duration_secs.store(0, Ordering::Relaxed);
}
PlayerCommand::SetVolume(_) | PlayerCommand::Seek(_) => {}
},
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
}
}

View File

@@ -14,7 +14,10 @@ const RING_BUFFER_SIZE: usize = 32 * 1024;
pub struct AudioOutput {
ring_buf_producer: rb::Producer<f32>,
viz_producer: Option<rb::Producer<f32>>,
_stream: cpal::Stream,
pub sample_rate: u32,
pub channels: usize,
}
impl AudioOutput {
@@ -49,10 +52,17 @@ impl AudioOutput {
Ok(Self {
ring_buf_producer: producer,
viz_producer: None,
_stream: stream,
sample_rate,
channels,
})
}
pub fn set_viz_producer(&mut self, producer: rb::Producer<f32>) {
self.viz_producer = Some(producer);
}
pub fn write(
&mut self,
decoded: AudioBufferRef<'_>,
@@ -66,6 +76,11 @@ impl AudioOutput {
sample_buf.copy_interleaved_ref(decoded);
let samples: Vec<f32> = sample_buf.samples().iter().map(|s| s * volume).collect();
// Best-effort copy for visualizer (non-blocking, ok to drop samples)
if let Some(ref mut viz) = self.viz_producer {
let _ = viz.write(&samples);
}
let mut remaining = &samples[..];
while !remaining.is_empty() {
if stop.load(Ordering::SeqCst) {

View File

@@ -25,6 +25,7 @@ target_sources(qobuz-qt PRIVATE
view/albumlistview.hpp
view/artistlistview.hpp
view/artistview.hpp
view/artistview.cpp
view/sidepanel/view.hpp
view/sidepanel/view.cpp
@@ -44,6 +45,9 @@ target_sources(qobuz-qt PRIVATE
dialog/settings.hpp
dialog/settings.cpp
# Scrobbler (header-only)
scrobbler/lastfm.hpp
# Widgets (header-only)
widget/clickableslider.hpp
widget/volumebutton.hpp

View File

@@ -8,9 +8,13 @@ QobuzBackend::QobuzBackend(QObject *parent)
: QObject(parent)
{
m_backend = qobuz_backend_new(&QobuzBackend::eventTrampoline, this);
if (!m_backend) {
qCritical("Failed to initialize Qobuz backend");
return;
}
m_positionTimer = new QTimer(this);
m_positionTimer->setInterval(500);
m_positionTimer->setInterval(50);
connect(m_positionTimer, &QTimer::timeout, this, &QobuzBackend::onPositionTick);
m_positionTimer->start();
}
@@ -35,6 +39,11 @@ void QobuzBackend::setToken(const QString &token)
qobuz_backend_set_token(m_backend, token.toUtf8().constData());
}
void QobuzBackend::getUser()
{
qobuz_backend_get_user(m_backend);
}
// ---- catalog ----
void QobuzBackend::search(const QString &query, quint32 offset, quint32 limit)
@@ -52,6 +61,19 @@ void QobuzBackend::getArtist(qint64 artistId)
qobuz_backend_get_artist(m_backend, artistId);
}
void QobuzBackend::getArtistReleases(qint64 artistId, const QString &releaseType, quint32 limit, quint32 offset)
{
qobuz_backend_get_artist_releases(m_backend, artistId,
releaseType.toUtf8().constData(), limit, offset);
}
void QobuzBackend::getAlbumsTracks(const QStringList &albumIds)
{
const QJsonArray arr = QJsonArray::fromStringList(albumIds);
const QByteArray json = QJsonDocument(arr).toJson(QJsonDocument::Compact);
qobuz_backend_get_albums_tracks(m_backend, json.constData());
}
void QobuzBackend::getPlaylist(qint64 playlistId, quint32 offset, quint32 limit)
{
qobuz_backend_get_playlist(m_backend, playlistId, offset, limit);
@@ -79,6 +101,45 @@ void QobuzBackend::getUserPlaylists(quint32 offset, quint32 limit)
qobuz_backend_get_user_playlists(m_backend, offset, limit);
}
// ---- playback options ----
void QobuzBackend::setReplayGain(bool enabled)
{
qobuz_backend_set_replaygain(m_backend, enabled);
}
void QobuzBackend::setGapless(bool enabled)
{
qobuz_backend_set_gapless(m_backend, enabled);
}
void QobuzBackend::prefetchTrack(qint64 trackId, int formatId)
{
qobuz_backend_prefetch_track(m_backend, trackId, formatId);
}
// ---- playlist management ----
void QobuzBackend::createPlaylist(const QString &name)
{
qobuz_backend_create_playlist(m_backend, name.toUtf8().constData());
}
void QobuzBackend::deletePlaylist(qint64 playlistId)
{
qobuz_backend_delete_playlist(m_backend, playlistId);
}
void QobuzBackend::addTrackToPlaylist(qint64 playlistId, qint64 trackId)
{
qobuz_backend_add_track_to_playlist(m_backend, playlistId, trackId);
}
void QobuzBackend::deleteTrackFromPlaylist(qint64 playlistId, qint64 playlistTrackId)
{
qobuz_backend_delete_track_from_playlist(m_backend, playlistId, playlistTrackId);
}
// ---- fav modification ----
void QobuzBackend::addFavTrack(qint64 trackId)
@@ -101,6 +162,16 @@ void QobuzBackend::removeFavAlbum(const QString &albumId)
qobuz_backend_remove_fav_album(m_backend, albumId.toUtf8().constData());
}
void QobuzBackend::addFavArtist(qint64 artistId)
{
qobuz_backend_add_fav_artist(m_backend, artistId);
}
void QobuzBackend::removeFavArtist(qint64 artistId)
{
qobuz_backend_remove_fav_artist(m_backend, artistId);
}
// ---- playback ----
void QobuzBackend::playTrack(qint64 trackId, int formatId)
@@ -128,11 +199,31 @@ void QobuzBackend::setVolume(int volume)
qobuz_backend_set_volume(m_backend, static_cast<quint8>(qBound(0, volume, 100)));
}
void QobuzBackend::seek(quint64 positionSecs)
{
qobuz_backend_seek(m_backend, positionSecs);
}
quint64 QobuzBackend::position() const { return qobuz_backend_get_position(m_backend); }
quint64 QobuzBackend::duration() const { return qobuz_backend_get_duration(m_backend); }
int QobuzBackend::volume() const { return qobuz_backend_get_volume(m_backend); }
int QobuzBackend::state() const { return qobuz_backend_get_state(m_backend); }
quint32 QobuzBackend::vizRead(float *buf, quint32 maxSamples)
{
return qobuz_backend_viz_read(m_backend, buf, maxSamples);
}
quint32 QobuzBackend::vizSampleRate() const
{
return qobuz_backend_viz_sample_rate(m_backend);
}
quint32 QobuzBackend::vizChannels() const
{
return qobuz_backend_viz_channels(m_backend);
}
// ---- private slots ----
void QobuzBackend::onPositionTick()
@@ -145,7 +236,12 @@ void QobuzBackend::onPositionTick()
void QobuzBackend::onEvent(int eventType, const QString &json)
{
const QJsonObject obj = QJsonDocument::fromJson(json.toUtf8()).object();
const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8());
if (!doc.isObject()) {
emit error(tr("Malformed response from backend"));
return;
}
const QJsonObject obj = doc.object();
switch (eventType) {
case EV_LOGIN_OK:
@@ -169,6 +265,17 @@ void QobuzBackend::onEvent(int eventType, const QString &json)
case EV_ARTIST_OK:
emit artistLoaded(obj);
break;
case 24: // EV_ARTIST_RELEASES_OK
emit artistReleasesLoaded(
obj["release_type"].toString(),
obj["items"].toArray(),
obj["has_more"].toBool(),
obj["offset"].toInt()
);
break;
case 25: // EV_DEEP_SHUFFLE_OK
emit deepShuffleTracksLoaded(obj["tracks"].toArray());
break;
case EV_ARTIST_ERR:
emit error(obj["error"].toString());
break;
@@ -196,6 +303,18 @@ void QobuzBackend::onEvent(int eventType, const QString &json)
case EV_STATE_CHANGED:
emit stateChanged(obj["state"].toString());
break;
case 20: // EV_PLAYLIST_CREATED
emit playlistCreated(obj);
break;
case 21: // EV_PLAYLIST_DELETED
emit playlistDeleted(obj);
break;
case 22: // EV_PLAYLIST_TRACK_ADDED
emit playlistTrackAdded(static_cast<qint64>(obj["playlist_id"].toDouble()));
break;
case EV_USER_OK:
emit userLoaded(obj);
break;
case EV_GENERIC_ERR:
case EV_TRACK_URL_ERR:
emit error(obj["error"].toString());

View File

@@ -4,6 +4,7 @@
#include <QObject>
#include <QString>
#include <QJsonArray>
#include <QJsonObject>
#include <QTimer>
@@ -23,11 +24,14 @@ public:
// --- auth ---
void login(const QString &email, const QString &password);
void setToken(const QString &token);
void getUser();
// --- catalog ---
void search(const QString &query, quint32 offset = 0, quint32 limit = 20);
void getAlbum(const QString &albumId);
void getArtist(qint64 artistId);
void getArtistReleases(qint64 artistId, const QString &releaseType, quint32 limit = 50, quint32 offset = 0);
void getAlbumsTracks(const QStringList &albumIds);
void getPlaylist(qint64 playlistId, quint32 offset = 0, quint32 limit = 500);
// --- favorites ---
@@ -36,11 +40,24 @@ public:
void getFavArtists(quint32 offset = 0, quint32 limit = 200);
void getUserPlaylists(quint32 offset = 0, quint32 limit = 200);
// --- playback options ---
void setReplayGain(bool enabled);
void setGapless(bool enabled);
void prefetchTrack(qint64 trackId, int formatId = 6);
// --- playlist management ---
void createPlaylist(const QString &name);
void deletePlaylist(qint64 playlistId);
void addTrackToPlaylist(qint64 playlistId, qint64 trackId);
void deleteTrackFromPlaylist(qint64 playlistId, qint64 playlistTrackId);
// --- fav modification ---
void addFavTrack(qint64 trackId);
void removeFavTrack(qint64 trackId);
void addFavAlbum(const QString &albumId);
void removeFavAlbum(const QString &albumId);
void addFavArtist(qint64 artistId);
void removeFavArtist(qint64 artistId);
// --- playback ---
void playTrack(qint64 trackId, int formatId = 6);
@@ -48,6 +65,7 @@ public:
void resume();
void stop();
void setVolume(int volume);
void seek(quint64 positionSecs);
quint64 position() const;
quint64 duration() const;
@@ -55,16 +73,27 @@ public:
/// 1 = playing, 2 = paused, 0 = idle
int state() const;
// --- visualizer PCM ---
quint32 vizRead(float *buf, quint32 maxSamples);
quint32 vizSampleRate() const;
quint32 vizChannels() const;
signals:
// auth
void loginSuccess(const QString &token, const QJsonObject &user);
void loginError(const QString &error);
void userLoaded(const QJsonObject &user);
// catalog
void searchResult(const QJsonObject &result);
void albumLoaded(const QJsonObject &album);
void artistLoaded(const QJsonObject &artist);
void artistReleasesLoaded(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset);
void deepShuffleTracksLoaded(const QJsonArray &tracks);
void playlistLoaded(const QJsonObject &playlist);
void playlistCreated(const QJsonObject &playlist);
void playlistDeleted(const QJsonObject &result);
void playlistTrackAdded(qint64 playlistId);
// favorites
void favTracksLoaded(const QJsonObject &result);

View File

@@ -1,15 +1,20 @@
#include "settings.hpp"
#include "../util/settings.hpp"
#include "../scrobbler/lastfm.hpp"
#include <QVBoxLayout>
#include <QFormLayout>
#include <QDialogButtonBox>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QNetworkReply>
SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent)
{
setWindowTitle(tr("Settings"));
setMinimumWidth(400);
setMinimumWidth(420);
m_nam = new QNetworkAccessManager(this);
auto *layout = new QVBoxLayout(this);
@@ -30,25 +35,109 @@ SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent)
break;
}
}
playLayout->addRow(tr("Preferred quality:"), m_formatBox);
m_replayGain = new QCheckBox(tr("Enable ReplayGain (track gain normalisation)"), playGroup);
m_replayGain->setChecked(AppSettings::instance().replayGainEnabled());
playLayout->addRow(m_replayGain);
m_gapless = new QCheckBox(tr("Gapless playback"), playGroup);
m_gapless->setChecked(AppSettings::instance().gaplessEnabled());
playLayout->addRow(m_gapless);
layout->addWidget(playGroup);
// --- Last.fm group ---
auto *lfmGroup = new QGroupBox(tr("Last.fm Scrobbling"), this);
auto *lfmLayout = new QFormLayout(lfmGroup);
m_lastFmEnabled = new QCheckBox(tr("Enable scrobbling"), lfmGroup);
m_lastFmEnabled->setChecked(AppSettings::instance().lastFmEnabled());
lfmLayout->addRow(m_lastFmEnabled);
m_lastFmApiKey = new QLineEdit(AppSettings::instance().lastFmApiKey(), lfmGroup);
m_lastFmApiKey->setPlaceholderText(tr("Get one at last.fm/api"));
lfmLayout->addRow(tr("API Key:"), m_lastFmApiKey);
m_lastFmApiSecret = new QLineEdit(AppSettings::instance().lastFmApiSecret(), lfmGroup);
m_lastFmApiSecret->setEchoMode(QLineEdit::Password);
lfmLayout->addRow(tr("API Secret:"), m_lastFmApiSecret);
m_lastFmUsername = new QLineEdit(lfmGroup);
m_lastFmUsername->setPlaceholderText(tr("Last.fm username"));
lfmLayout->addRow(tr("Username:"), m_lastFmUsername);
m_lastFmPassword = new QLineEdit(lfmGroup);
m_lastFmPassword->setEchoMode(QLineEdit::Password);
m_lastFmPassword->setPlaceholderText(tr("Last.fm password"));
lfmLayout->addRow(tr("Password:"), m_lastFmPassword);
auto *connectRow = new QHBoxLayout;
m_lastFmConnect = new QPushButton(tr("Connect"), lfmGroup);
m_lastFmStatus = new QLabel(lfmGroup);
const bool hasSession = !AppSettings::instance().lastFmSessionKey().isEmpty();
m_lastFmStatus->setText(hasSession ? tr("Connected ✓") : tr("Not connected"));
m_lastFmStatus->setStyleSheet(hasSession ? "color: green;" : "color: gray;");
connectRow->addWidget(m_lastFmConnect);
connectRow->addWidget(m_lastFmStatus, 1);
lfmLayout->addRow(connectRow);
layout->addWidget(lfmGroup);
layout->addStretch();
auto *buttons = new QDialogButtonBox(
QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
layout->addWidget(buttons);
connect(buttons, &QDialogButtonBox::accepted, this, [this] {
applyChanges();
accept();
});
connect(m_lastFmConnect, &QPushButton::clicked, this, &SettingsDialog::onLastFmConnect);
connect(buttons, &QDialogButtonBox::accepted, this, [this] { applyChanges(); accept(); });
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
}
void SettingsDialog::applyChanges()
{
AppSettings::instance().setPreferredFormat(
m_formatBox->currentData().toInt());
AppSettings::instance().setPreferredFormat(m_formatBox->currentData().toInt());
AppSettings::instance().setReplayGainEnabled(m_replayGain->isChecked());
AppSettings::instance().setGaplessEnabled(m_gapless->isChecked());
AppSettings::instance().setLastFmEnabled(m_lastFmEnabled->isChecked());
AppSettings::instance().setLastFmApiKey(m_lastFmApiKey->text().trimmed());
AppSettings::instance().setLastFmApiSecret(m_lastFmApiSecret->text().trimmed());
}
void SettingsDialog::onLastFmConnect()
{
const QString apiKey = m_lastFmApiKey->text().trimmed();
const QString apiSecret = m_lastFmApiSecret->text().trimmed();
const QString username = m_lastFmUsername->text().trimmed();
const QString password = m_lastFmPassword->text();
if (apiKey.isEmpty() || apiSecret.isEmpty() || username.isEmpty() || password.isEmpty()) {
m_lastFmStatus->setText(tr("Fill in all fields first."));
m_lastFmStatus->setStyleSheet("color: red;");
return;
}
// Temporarily apply so the scrobbler util can use them for signing
AppSettings::instance().setLastFmApiKey(apiKey);
AppSettings::instance().setLastFmApiSecret(apiSecret);
m_lastFmConnect->setEnabled(false);
m_lastFmStatus->setText(tr("Connecting…"));
m_lastFmStatus->setStyleSheet("color: gray;");
// Reuse LastFmScrobbler::authenticate as a utility (temp instance)
auto *tmp = new LastFmScrobbler(this);
tmp->authenticate(username, password, [this, tmp](bool ok, const QString &err) {
tmp->deleteLater();
m_lastFmConnect->setEnabled(true);
if (ok) {
m_lastFmStatus->setText(tr("Connected ✓"));
m_lastFmStatus->setStyleSheet("color: green;");
} else {
m_lastFmStatus->setText(tr("Error: %1").arg(err));
m_lastFmStatus->setStyleSheet("color: red;");
}
});
}

View File

@@ -3,6 +3,10 @@
#include <QDialog>
#include <QComboBox>
#include <QLabel>
#include <QLineEdit>
#include <QCheckBox>
#include <QPushButton>
#include <QNetworkAccessManager>
class SettingsDialog : public QDialog
{
@@ -12,7 +16,21 @@ public:
explicit SettingsDialog(QWidget *parent = nullptr);
private:
// Playback
QComboBox *m_formatBox = nullptr;
QCheckBox *m_replayGain = nullptr;
QCheckBox *m_gapless = nullptr;
// Last.fm
QCheckBox *m_lastFmEnabled = nullptr;
QLineEdit *m_lastFmApiKey = nullptr;
QLineEdit *m_lastFmApiSecret = nullptr;
QLineEdit *m_lastFmUsername = nullptr;
QLineEdit *m_lastFmPassword = nullptr;
QPushButton *m_lastFmConnect = nullptr;
QLabel *m_lastFmStatus = nullptr;
QNetworkAccessManager *m_nam = nullptr;
void applyChanges();
void onLastFmConnect();
};

View File

@@ -3,6 +3,9 @@
#include <QHeaderView>
#include <QJsonArray>
#include <QJsonObject>
#include <QInputDialog>
#include <QMenu>
#include <QMessageBox>
namespace List
{
@@ -10,6 +13,7 @@ namespace List
static constexpr int TypeRole = Qt::UserRole + 1;
static constexpr int IdRole = Qt::UserRole + 2;
static constexpr int NameRole = Qt::UserRole + 3;
static constexpr int IsOwnerRole = Qt::UserRole + 4;
enum NodeType {
NodeFavTracks,
@@ -24,16 +28,29 @@ Library::Library(QobuzBackend *backend, QWidget *parent)
{
setHeaderHidden(true);
setRootIsDecorated(true);
setContextMenuPolicy(Qt::NoContextMenu);
setContextMenuPolicy(Qt::CustomContextMenu);
buildStaticNodes();
connect(m_backend, &QobuzBackend::userPlaylistsLoaded,
this, &Library::onUserPlaylistsLoaded);
connect(m_backend, &QobuzBackend::playlistCreated,
this, [this](const QJsonObject &) { refresh(); });
connect(m_backend, &QobuzBackend::playlistDeleted,
this, [this](const QJsonObject &result) {
const qint64 deletedId = static_cast<qint64>(result["playlist_id"].toDouble());
if (deletedId == m_openPlaylistId) {
m_openPlaylistId = 0;
emit openPlaylistDeleted();
}
refresh();
});
connect(this, &QTreeWidget::itemClicked,
this, &Library::onItemClicked);
connect(this, &QTreeWidget::itemDoubleClicked,
this, &Library::onItemDoubleClicked);
connect(this, &QTreeWidget::customContextMenuRequested,
this, &Library::onContextMenuRequested);
}
void Library::buildStaticNodes()
@@ -70,17 +87,75 @@ void Library::onUserPlaylistsLoaded(const QJsonObject &result)
while (m_playlistsNode->childCount() > 0)
delete m_playlistsNode->takeChild(0);
QVector<QPair<qint64, QString>> editablePlaylists;
const qint64 myUserId = AppSettings::instance().userId();
const QJsonArray items = result["items"].toArray();
for (const auto &v : items) {
const QJsonObject pl = v.toObject();
const QString name = pl["name"].toString();
const qint64 id = static_cast<qint64>(pl["id"].toDouble());
const qint64 ownId = static_cast<qint64>(pl["owner"].toObject()["id"].toDouble());
const bool isOwner = (myUserId > 0 && ownId == myUserId);
auto *item = new QTreeWidgetItem(m_playlistsNode, QStringList{name});
item->setData(0, TypeRole, NodePlaylist);
item->setData(0, IdRole, id);
item->setData(0, NameRole, name);
item->setData(0, IsOwnerRole, isOwner);
// Only include playlists we can edit in the "Add to playlist" submenu
if (isOwner)
editablePlaylists.append({id, name});
}
emit userPlaylistsChanged(editablePlaylists);
}
void Library::onContextMenuRequested(const QPoint &pos)
{
QTreeWidgetItem *item = itemAt(pos);
if (!item) return;
const bool isHeader = (item == m_playlistsNode);
const bool isPlaylistItem = (!isHeader && item->parent() == m_playlistsNode &&
item->data(0, TypeRole).toInt() == NodePlaylist);
if (!isHeader && !isPlaylistItem) return;
QMenu menu(this);
auto *newPl = menu.addAction(tr("New Playlist…"));
connect(newPl, &QAction::triggered, this, [this] {
bool ok = false;
const QString name = QInputDialog::getText(
this, tr("New Playlist"), tr("Playlist name:"),
QLineEdit::Normal, QString(), &ok);
if (ok && !name.trimmed().isEmpty())
m_backend->createPlaylist(name.trimmed());
});
if (isPlaylistItem) {
const qint64 plId = item->data(0, IdRole).toLongLong();
const QString plName = item->data(0, NameRole).toString();
const bool isOwner = item->data(0, IsOwnerRole).toBool();
if (isOwner) {
menu.addSeparator();
auto *delPl = menu.addAction(tr("Delete \"%1\"").arg(plName));
connect(delPl, &QAction::triggered, this, [this, plId, plName] {
const auto answer = QMessageBox::question(
this,
tr("Delete Playlist"),
tr("Permanently delete \"%1\"? This cannot be undone.").arg(plName),
QMessageBox::Yes | QMessageBox::Cancel,
QMessageBox::Cancel);
if (answer == QMessageBox::Yes)
m_backend->deletePlaylist(plId);
});
}
}
menu.exec(viewport()->mapToGlobal(pos));
}
void Library::onItemClicked(QTreeWidgetItem *item, int)
@@ -95,6 +170,7 @@ void Library::onItemClicked(QTreeWidgetItem *item, int)
case NodePlaylist: {
const qint64 id = item->data(0, IdRole).toLongLong();
const QString name = item->data(0, NameRole).toString();
m_openPlaylistId = id;
emit playlistRequested(id, name);
break;
}

View File

@@ -1,8 +1,12 @@
#pragma once
#include "../backend/qobuzbackend.hpp"
#include "../util/settings.hpp"
#include <QTreeWidget>
#include <QVector>
#include <QPair>
#include <QString>
namespace List
{
@@ -23,17 +27,23 @@ namespace List
void favAlbumsRequested();
void favArtistsRequested();
void playlistRequested(qint64 playlistId, const QString &name);
/// Emitted after playlists are loaded so others can cache the list.
void userPlaylistsChanged(const QVector<QPair<qint64, QString>> &playlists);
/// Emitted when the currently open playlist was deleted.
void openPlaylistDeleted();
private slots:
void onUserPlaylistsLoaded(const QJsonObject &result);
void onItemClicked(QTreeWidgetItem *item, int column);
void onItemDoubleClicked(QTreeWidgetItem *item, int column);
void onContextMenuRequested(const QPoint &pos);
private:
QobuzBackend *m_backend = nullptr;
QTreeWidgetItem *m_myLibNode = nullptr;
QTreeWidgetItem *m_playlistsNode = nullptr;
qint64 m_openPlaylistId = 0;
void buildStaticNodes();
};

View File

@@ -1,4 +1,6 @@
#include "tracks.hpp"
#include "../util/settings.hpp"
#include "../util/trackinfo.hpp"
#include <QHeaderView>
#include <QMenu>
@@ -34,42 +36,103 @@ Tracks::Tracks(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
this, &Tracks::onDoubleClicked);
connect(this, &QTreeView::customContextMenuRequested,
this, &Tracks::onContextMenu);
connect(m_model, &QAbstractItemModel::modelReset, this, [this] {
for (int row : m_model->discHeaderRows())
setFirstColumnSpanned(row, {}, true);
setSortingEnabled(!m_model->hasMultipleDiscs());
});
}
void Tracks::loadTracks(const QJsonArray &tracks)
{
setPlaylistContext(0);
setColumnHidden(TrackListModel::ColAlbum, false);
m_model->setTracks(tracks, false, /*useSequential=*/true);
}
void Tracks::loadAlbum(const QJsonObject &album)
{
setPlaylistContext(0);
setColumnHidden(TrackListModel::ColAlbum, true);
const QJsonArray items = album["tracks"].toObject()["items"].toArray();
m_model->setTracks(items); // album: use track_number
}
void Tracks::loadPlaylist(const QJsonObject &playlist)
{
setColumnHidden(TrackListModel::ColAlbum, false);
const qint64 id = static_cast<qint64>(playlist["id"].toDouble());
const qint64 ownId = static_cast<qint64>(playlist["owner"].toObject()["id"].toDouble());
const qint64 myId = AppSettings::instance().userId();
const bool isOwned = (myId > 0 && ownId == myId);
setPlaylistContext(id, isOwned);
const QJsonArray items = playlist["tracks"].toObject()["items"].toArray();
m_model->setTracks(items, /*usePosition=*/true);
}
void Tracks::loadSearchTracks(const QJsonArray &tracks)
{
setPlaylistContext(0);
setColumnHidden(TrackListModel::ColAlbum, false);
m_model->setTracks(tracks, false, /*useSequential=*/true);
}
void Tracks::setPlaylistContext(qint64 playlistId, bool isOwned)
{
m_playlistId = playlistId;
m_playlistIsOwned = isOwned;
}
void Tracks::setUserPlaylists(const QVector<QPair<qint64, QString>> &playlists)
{
m_userPlaylists = playlists;
}
void Tracks::setPlayingTrackId(qint64 id)
{
m_model->setPlayingId(id);
}
void Tracks::setFavTrackIds(const QSet<qint64> &ids)
{
m_model->setFavIds(ids);
}
void Tracks::addFavTrackId(qint64 id)
{
m_model->addFavId(id);
}
void Tracks::removeFavTrackId(qint64 id)
{
m_model->removeFavId(id);
}
void Tracks::playAll(bool shuffle)
{
const QJsonArray tracks = m_model->currentTracksJson();
if (tracks.isEmpty()) return;
m_queue->setContext(tracks, 0);
// Shuffle once without touching the global shuffle flag — so a subsequent
// double-click on a track plays in normal order (unless global shuffle is on).
if (shuffle && !m_queue->shuffleEnabled())
m_queue->shuffleNow();
const qint64 firstId = static_cast<qint64>(m_queue->current()["id"].toDouble());
if (firstId > 0)
emit playTrackRequested(firstId);
}
void Tracks::onDoubleClicked(const QModelIndex &index)
{
const qint64 id = m_model->data(index, TrackListModel::TrackIdRole).toLongLong();
if (id > 0) {
m_queue->setContext(m_model->currentTracksJson(), index.row());
// Compute filtered row (disc headers excluded from currentTracksJson)
int filteredRow = 0;
for (int r = 0; r < index.row(); ++r)
if (!m_model->trackAt(r).isDiscHeader) ++filteredRow;
m_queue->setContext(m_model->currentTracksJson(), filteredRow);
emit playTrackRequested(id);
}
}
@@ -80,6 +143,7 @@ void Tracks::onContextMenu(const QPoint &pos)
if (!index.isValid()) return;
const qint64 id = m_model->data(index, TrackListModel::TrackIdRole).toLongLong();
if (id <= 0) return; // disc header row
const QJsonObject trackJson = m_model->data(index, TrackListModel::TrackJsonRole).toJsonObject();
QMenu menu(this);
@@ -88,12 +152,28 @@ void Tracks::onContextMenu(const QPoint &pos)
auto *playNext = menu.addAction(QIcon(":/res/icons/media-skip-forward.svg"), tr("Play next"));
auto *addQueue = menu.addAction(QIcon(":/res/icons/media-playlist-append.svg"), tr("Add to queue"));
menu.addSeparator();
auto *addFav = menu.addAction(QIcon(":/res/icons/starred-symbolic.svg"), tr("Add to favorites"));
auto *remFav = menu.addAction(QIcon(":/res/icons/non-starred-symbolic.svg"), tr("Remove from favorites"));
const int row = index.row();
connect(playNow, &QAction::triggered, this, [this, id, row] {
m_queue->setContext(m_model->currentTracksJson(), row);
const bool isFav = m_model->isFav(id);
if (isFav) {
auto *remFav = menu.addAction(QIcon(":/res/icons/non-starred-symbolic.svg"), tr("Remove from favorites"));
connect(remFav, &QAction::triggered, this, [this, id] {
m_backend->removeFavTrack(id);
m_model->removeFavId(id);
});
} else {
auto *addFav = menu.addAction(QIcon(":/res/icons/starred-symbolic.svg"), tr("Add to favorites"));
connect(addFav, &QAction::triggered, this, [this, id] {
m_backend->addFavTrack(id);
m_model->addFavId(id);
});
}
// Compute filtered row for multi-disc albums (disc headers excluded from currentTracksJson)
int filteredRow = 0;
for (int r = 0; r < index.row(); ++r)
if (!m_model->trackAt(r).isDiscHeader) ++filteredRow;
connect(playNow, &QAction::triggered, this, [this, id, filteredRow] {
m_queue->setContext(m_model->currentTracksJson(), filteredRow);
emit playTrackRequested(id);
});
connect(playNext, &QAction::triggered, this, [this, trackJson] {
@@ -102,12 +182,6 @@ void Tracks::onContextMenu(const QPoint &pos)
connect(addQueue, &QAction::triggered, this, [this, trackJson] {
m_queue->addToQueue(trackJson);
});
connect(addFav, &QAction::triggered, this, [this, id] {
m_backend->addFavTrack(id);
});
connect(remFav, &QAction::triggered, this, [this, id] {
m_backend->removeFavTrack(id);
});
// Open album
const QString albumId = m_model->trackAt(index.row()).albumId;
@@ -134,6 +208,43 @@ void Tracks::onContextMenu(const QPoint &pos)
});
}
// Playlist management
if (!m_userPlaylists.isEmpty()) {
menu.addSeparator();
auto *addToPlMenu = menu.addMenu(
QIcon(":/res/icons/media-playlist-append.svg"), tr("Add to playlist"));
for (const auto &pl : m_userPlaylists) {
const qint64 plId = pl.first;
const QString plName = pl.second;
auto *act = addToPlMenu->addAction(plName);
connect(act, &QAction::triggered, this, [this, id, plId] {
emit addToPlaylistRequested(id, plId);
});
}
}
if (m_playlistId > 0 && m_playlistIsOwned) {
const qint64 playlistTrackId =
m_model->data(index, TrackListModel::PlaylistTrackIdRole).toLongLong();
if (playlistTrackId > 0) {
if (m_userPlaylists.isEmpty()) menu.addSeparator();
auto *remFromPl = menu.addAction(tr("Remove from this playlist"));
const qint64 curPlaylistId = m_playlistId;
const int curRow = index.row();
connect(remFromPl, &QAction::triggered, this, [this, curPlaylistId, playlistTrackId, curRow] {
emit removeFromPlaylistRequested(curPlaylistId, playlistTrackId);
m_model->removeTrack(curRow); // optimistic: remove immediately from view
});
}
}
// Track info
menu.addSeparator();
auto *infoAction = menu.addAction(tr("Track info..."));
connect(infoAction, &QAction::triggered, this, [this, trackJson] {
TrackInfoDialog::show(trackJson, this);
});
menu.exec(viewport()->mapToGlobal(pos));
}

View File

@@ -7,6 +7,10 @@
#include <QTreeView>
#include <QJsonArray>
#include <QJsonObject>
#include <QVector>
#include <QPair>
#include <QString>
#include <QSet>
namespace List
{
@@ -25,13 +29,34 @@ namespace List
/// Called when the backend fires EV_TRACK_CHANGED so the playing row is highlighted.
void setPlayingTrackId(qint64 id);
/// Populate favorite track IDs so the star indicator and context menu reflect fav status.
void setFavTrackIds(const QSet<qint64> &ids);
void addFavTrackId(qint64 id);
void removeFavTrackId(qint64 id);
/// Start playing all tracks in the current view from the beginning.
/// If shuffle is true, enables shuffle mode before starting.
void playAll(bool shuffle = false);
/// Set which playlist is currently displayed (0 = none).
/// isOwned controls whether "Remove from this playlist" is shown.
void setPlaylistContext(qint64 playlistId, bool isOwned = false);
qint64 playlistId() const { return m_playlistId; }
/// Provide the user's playlist list for the "Add to playlist" submenu.
void setUserPlaylists(const QVector<QPair<qint64, QString>> &playlists);
signals:
void playTrackRequested(qint64 trackId);
void addToPlaylistRequested(qint64 trackId, qint64 playlistId);
void removeFromPlaylistRequested(qint64 playlistId, qint64 playlistTrackId);
private:
TrackListModel *m_model = nullptr;
QobuzBackend *m_backend = nullptr;
PlayQueue *m_queue = nullptr;
qint64 m_playlistId = 0;
bool m_playlistIsOwned = false;
QVector<QPair<qint64, QString>> m_userPlaylists;
void onDoubleClicked(const QModelIndex &index);
void onContextMenu(const QPoint &pos);

View File

@@ -11,26 +11,67 @@ int main(int argc, char *argv[])
app.setOrganizationName(QStringLiteral("qobuz-qt"));
app.setApplicationVersion(QStringLiteral("0.1.0"));
// Dark palette consistent with spotify-qt style
// Qobuz dark palette
// Accent: #FFB232 (yellow-orange), Blue: #46B3EE, Backgrounds: #191919 / #141414
app.setStyle(QStyleFactory::create(QStringLiteral("Fusion")));
QPalette darkPalette;
darkPalette.setColor(QPalette::Window, QColor(35, 35, 35));
darkPalette.setColor(QPalette::WindowText, Qt::white);
darkPalette.setColor(QPalette::Base, QColor(25, 25, 25));
darkPalette.setColor(QPalette::AlternateBase, QColor(45, 45, 45));
darkPalette.setColor(QPalette::ToolTipBase, Qt::white);
darkPalette.setColor(QPalette::ToolTipText, Qt::white);
darkPalette.setColor(QPalette::Text, Qt::white);
darkPalette.setColor(QPalette::Button, QColor(53, 53, 53));
darkPalette.setColor(QPalette::ButtonText, Qt::white);
darkPalette.setColor(QPalette::BrightText, Qt::red);
darkPalette.setColor(QPalette::Link, QColor(42, 130, 218));
darkPalette.setColor(QPalette::Highlight, QColor(42, 130, 218));
darkPalette.setColor(QPalette::HighlightedText, Qt::black);
darkPalette.setColor(QPalette::Disabled, QPalette::Text, QColor(127, 127, 127));
darkPalette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor(127, 127, 127));
darkPalette.setColor(QPalette::Window, QColor(0x19, 0x19, 0x19));
darkPalette.setColor(QPalette::WindowText, QColor(0xe8, 0xe8, 0xe8));
darkPalette.setColor(QPalette::Base, QColor(0x14, 0x14, 0x14));
darkPalette.setColor(QPalette::AlternateBase, QColor(0x1e, 0x1e, 0x1e));
darkPalette.setColor(QPalette::ToolTipBase, QColor(0x19, 0x19, 0x19));
darkPalette.setColor(QPalette::ToolTipText, QColor(0xe8, 0xe8, 0xe8));
darkPalette.setColor(QPalette::Text, QColor(0xe8, 0xe8, 0xe8));
darkPalette.setColor(QPalette::Button, QColor(0x2a, 0x2a, 0x2a));
darkPalette.setColor(QPalette::ButtonText, QColor(0xe8, 0xe8, 0xe8));
darkPalette.setColor(QPalette::BrightText, QColor(0xFF, 0xB2, 0x32));
darkPalette.setColor(QPalette::Link, QColor(0x46, 0xB3, 0xEE)); // Qobuz blue
darkPalette.setColor(QPalette::Highlight, QColor(0xFF, 0xB2, 0x32)); // Qobuz orange
darkPalette.setColor(QPalette::HighlightedText, QColor(0x10, 0x10, 0x10)); // dark on orange
darkPalette.setColor(QPalette::PlaceholderText, QColor(0x66, 0x66, 0x66));
darkPalette.setColor(QPalette::Disabled, QPalette::Text, QColor(0x55, 0x55, 0x55));
darkPalette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor(0x55, 0x55, 0x55));
darkPalette.setColor(QPalette::Mid, QColor(0x2f, 0x2f, 0x2f));
darkPalette.setColor(QPalette::Dark, QColor(0x0e, 0x0e, 0x0e));
app.setPalette(darkPalette);
// Stylesheet tweaks: orange accent on scrollbars, focus rings, etc.
app.setStyleSheet(QStringLiteral(
"QScrollBar:vertical { width: 6px; background: #141414; border: none; }"
"QScrollBar::handle:vertical { background: #3a3a3a; border-radius: 3px; min-height: 20px; }"
"QScrollBar::handle:vertical:hover { background: #FFB232; }"
"QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }"
"QScrollBar:horizontal { height: 6px; background: #141414; border: none; }"
"QScrollBar::handle:horizontal { background: #3a3a3a; border-radius: 3px; min-width: 20px; }"
"QScrollBar::handle:horizontal:hover { background: #FFB232; }"
"QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }"
"QToolBar { background: #111111; border-bottom: 1px solid #2a2a2a; spacing: 4px; }"
"QDockWidget { border: none; }"
"QDockWidget::title { background: #1e1e1e; padding: 4px 8px; font-weight: bold; }"
"QTreeView, QTreeWidget { border: none; outline: none; }"
"QTreeView::item:selected, QTreeWidget::item:selected { color: #101010; }"
"QHeaderView::section { background: #1e1e1e; border: none;"
" border-right: 1px solid #2a2a2a; padding: 4px 8px; }"
"QMenu { background: #1e1e1e; border: 1px solid #3a3a3a; }"
"QMenu::item:selected { background: #FFB232; color: #101010; }"
"QPushButton { background: #2a2a2a; border: 1px solid #3a3a3a; border-radius: 4px; padding: 4px 12px; }"
"QPushButton:hover { background: #333333; border-color: #FFB232; }"
"QPushButton:pressed { background: #FFB232; color: #101010; }"
"QComboBox { background: #2a2a2a; border: 1px solid #3a3a3a; border-radius: 4px; padding: 3px 8px; }"
"QComboBox:hover { border-color: #FFB232; }"
"QComboBox QAbstractItemView { background: #1e1e1e; selection-background-color: #FFB232; selection-color: #101010; }"
"QLineEdit { background: #1e1e1e; border: 1px solid #3a3a3a; border-radius: 4px; padding: 4px 8px; }"
"QLineEdit:focus { border-color: #FFB232; }"
"QGroupBox { border: 1px solid #2f2f2f; border-radius: 6px; margin-top: 8px; padding-top: 4px; }"
"QGroupBox::title { subcontrol-origin: margin; left: 8px; color: #FFB232; }"
"QCheckBox::indicator:checked { background: #FFB232; border: 1px solid #FFB232; border-radius: 2px; }"
"QSlider::groove:horizontal { height: 4px; background: #2a2a2a; border-radius: 2px; }"
"QSlider::handle:horizontal { background: #FFB232; width: 12px; height: 12px;"
" margin: -4px 0; border-radius: 6px; }"
"QSlider::sub-page:horizontal { background: #FFB232; border-radius: 2px; }"
"QStatusBar { background: #111111; border-top: 1px solid #2a2a2a; }"
));
auto *backend = new QobuzBackend;
MainWindow window(backend);
window.show();

View File

@@ -40,7 +40,8 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
m_libraryDock->setObjectName(QStringLiteral("libraryDock"));
m_libraryDock->setFeatures(QDockWidget::DockWidgetMovable);
m_libraryDock->setWidget(m_library);
m_libraryDock->setMinimumWidth(200);
m_libraryDock->setMinimumWidth(180);
m_library->setFixedWidth(220);
addDockWidget(Qt::LeftDockWidgetArea, m_libraryDock);
// ---- Now-playing context dock (left, below library) ----
@@ -53,27 +54,66 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
addDockWidget(Qt::RightDockWidgetArea, m_queuePanel);
// ---- Search side panel (right) ----
m_sidePanel = new SidePanel::View(m_backend, this);
m_sidePanel = new SidePanel::View(m_backend, m_queue, this);
m_sidePanel->hide();
addDockWidget(Qt::RightDockWidgetArea, m_sidePanel);
setupMenuBar();
statusBar()->showMessage(tr("Ready"));
// ---- Scrobbler ----
m_scrobbler = new LastFmScrobbler(this);
connect(m_backend, &QobuzBackend::trackChanged,
m_scrobbler, &LastFmScrobbler::onTrackStarted);
connect(m_backend, &QobuzBackend::positionChanged,
m_scrobbler, &LastFmScrobbler::onPositionChanged);
connect(m_backend, &QobuzBackend::trackFinished,
m_scrobbler, &LastFmScrobbler::onTrackFinished);
// ---- Backend signals ----
connect(m_backend, &QobuzBackend::loginSuccess, this, &MainWindow::onLoginSuccess);
connect(m_backend, &QobuzBackend::loginError, this, &MainWindow::onLoginError);
connect(m_backend, &QobuzBackend::userLoaded, this, [this](const QJsonObject &user) {
const qint64 id = static_cast<qint64>(user["id"].toDouble());
if (id > 0) {
AppSettings::instance().setUserId(id);
m_library->refresh(); // re-load playlists with correct ownership now
}
});
connect(m_backend, &QobuzBackend::favTracksLoaded, this, &MainWindow::onFavTracksLoaded);
connect(m_backend, &QobuzBackend::favAlbumsLoaded, this, &MainWindow::onFavAlbumsLoaded);
connect(m_backend, &QobuzBackend::favArtistsLoaded, this, &MainWindow::onFavArtistsLoaded);
connect(m_backend, &QobuzBackend::albumLoaded, this, &MainWindow::onAlbumLoaded);
connect(m_backend, &QobuzBackend::artistLoaded, this, &MainWindow::onArtistLoaded);
connect(m_backend, &QobuzBackend::artistReleasesLoaded,
m_content, &MainContent::updateArtistReleases);
connect(m_backend, &QobuzBackend::deepShuffleTracksLoaded,
m_content, &MainContent::onDeepShuffleTracks);
connect(m_backend, &QobuzBackend::playlistLoaded, this, &MainWindow::onPlaylistLoaded);
connect(m_backend, &QobuzBackend::playlistCreated, this, &MainWindow::onPlaylistCreated);
connect(m_backend, &QobuzBackend::playlistDeleted, this, [this](const QJsonObject &) {
// status bar message is also shown by library's openPlaylistDeleted handler
});
connect(m_backend, &QobuzBackend::playlistTrackAdded, this, [this](qint64 playlistId) {
// Refresh the currently shown playlist if a track was added to it
if (m_content->tracksList()->playlistId() == playlistId)
m_backend->getPlaylist(playlistId);
statusBar()->showMessage(tr("Track added to playlist"), 3000);
});
connect(m_backend, &QobuzBackend::trackChanged, this, &MainWindow::onTrackChanged);
connect(m_backend, &QobuzBackend::error, this, [this](const QString &msg) {
statusBar()->showMessage(tr("Error: %1").arg(msg), 6000);
});
// ---- Library signals ----
connect(m_library, &List::Library::userPlaylistsChanged,
this, &MainWindow::onUserPlaylistsChanged);
connect(m_library, &List::Library::openPlaylistDeleted,
this, [this] {
m_content->showWelcome();
statusBar()->showMessage(tr("Playlist deleted"), 3000);
});
// ---- Library → backend ----
connect(m_library, &List::Library::favTracksRequested, this, [this] {
m_backend->getFavTracks();
@@ -84,6 +124,7 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
statusBar()->showMessage(tr("Loading favorite albums…"));
});
connect(m_library, &List::Library::favArtistsRequested, this, [this] {
m_showFavArtistsOnLoad = true;
m_backend->getFavArtists();
statusBar()->showMessage(tr("Loading favorite artists…"));
});
@@ -93,9 +134,19 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
statusBar()->showMessage(tr("Loading playlist: %1…").arg(name));
});
// ---- Track list → playback ----
// ---- Track list → playback / playlist management ----
connect(m_content->tracksList(), &List::Tracks::playTrackRequested,
this, &MainWindow::onPlayTrackRequested);
connect(m_content->tracksList(), &List::Tracks::addToPlaylistRequested,
this, [this](qint64 trackId, qint64 playlistId) {
m_backend->addTrackToPlaylist(playlistId, trackId);
statusBar()->showMessage(tr("Adding track to playlist…"), 3000);
});
connect(m_content->tracksList(), &List::Tracks::removeFromPlaylistRequested,
this, [this](qint64 playlistId, qint64 playlistTrackId) {
m_backend->deleteTrackFromPlaylist(playlistId, playlistTrackId);
statusBar()->showMessage(tr("Removing track from playlist…"), 3000);
});
// ---- Search panel ----
connect(m_sidePanel, &SidePanel::View::albumSelected,
@@ -104,18 +155,36 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
this, &MainWindow::onSearchArtistSelected);
connect(m_sidePanel, &SidePanel::View::trackPlayRequested,
this, &MainWindow::onPlayTrackRequested);
connect(m_sidePanel, &SidePanel::View::addToPlaylistRequested,
this, [this](qint64 trackId, qint64 playlistId) {
m_backend->addTrackToPlaylist(playlistId, trackId);
statusBar()->showMessage(tr("Adding track to playlist..."), 3000);
});
// ---- Album / artist navigation from content views ----
connect(m_content, &MainContent::albumRequested,
this, &MainWindow::onSearchAlbumSelected);
connect(m_content, &MainContent::artistRequested,
this, &MainWindow::onSearchArtistSelected);
connect(m_content, &MainContent::playTrackRequested,
this, &MainWindow::onPlayTrackRequested);
// ---- Queue panel ----
connect(m_queuePanel, &QueuePanel::skipToTrackRequested,
this, &MainWindow::onPlayTrackRequested);
// ---- Toolbar toggles ----
connect(m_toolBar, &MainToolBar::searchToggled, this, &MainWindow::onSearchToggled);
connect(m_toolBar, &MainToolBar::queueToggled,
this, [this](bool v) { m_queuePanel->setVisible(v); });
connect(m_toolBar, &MainToolBar::albumRequested, this, &MainWindow::onSearchAlbumSelected);
connect(m_toolBar, &MainToolBar::artistRequested, this, &MainWindow::onSearchArtistSelected);
// Apply playback options from saved settings
m_backend->setReplayGain(AppSettings::instance().replayGainEnabled());
m_backend->setGapless(AppSettings::instance().gaplessEnabled());
tryRestoreSession();
}
@@ -153,7 +222,12 @@ void MainWindow::tryRestoreSession()
const QString token = AppSettings::instance().authToken();
if (!token.isEmpty()) {
m_backend->setToken(token);
if (AppSettings::instance().userId() == 0)
m_backend->getUser(); // userLoaded will call m_library->refresh()
else
m_library->refresh();
// Preload fav artists so the artist page fav button works immediately
m_backend->getFavArtists();
const QString name = AppSettings::instance().displayName();
statusBar()->showMessage(tr("Signed in as %1").arg(
name.isEmpty() ? AppSettings::instance().userEmail() : name));
@@ -197,6 +271,9 @@ void MainWindow::onLoginSuccess(const QString &token, const QJsonObject &user)
const QString email = user["email"].toString();
AppSettings::instance().setDisplayName(displayName);
AppSettings::instance().setUserEmail(email);
const qint64 userId = static_cast<qint64>(user["id"].toDouble());
if (userId > 0)
AppSettings::instance().setUserId(userId);
statusBar()->showMessage(tr("Signed in as %1").arg(
displayName.isEmpty() ? email : displayName));
m_library->refresh();
@@ -220,10 +297,29 @@ void MainWindow::onTrackChanged(const QJsonObject &track)
: track["performer"].toObject()["name"].toString();
statusBar()->showMessage(
artist.isEmpty() ? title : QStringLiteral("▶ %1 — %2").arg(artist, title));
// Prefetch next track URL when gapless is enabled
if (AppSettings::instance().gaplessEnabled() && m_queue->canGoNext()) {
const auto upcoming = m_queue->upcomingTracks(1);
if (!upcoming.isEmpty()) {
const qint64 nextId = static_cast<qint64>(upcoming.first()["id"].toDouble());
if (nextId > 0)
m_backend->prefetchTrack(nextId, AppSettings::instance().preferredFormat());
}
}
}
void MainWindow::onFavTracksLoaded(const QJsonObject &result)
{
// Cache fav IDs so the star indicator and context menu stay in sync
QSet<qint64> ids;
const QJsonArray items = result["items"].toArray();
for (const QJsonValue &v : items) {
const qint64 id = static_cast<qint64>(v.toObject()["id"].toDouble());
if (id > 0) ids.insert(id);
}
m_content->tracksList()->setFavTrackIds(ids);
m_content->showFavTracks(result);
statusBar()->showMessage(
tr("%1 favorite tracks").arg(result["total"].toInt()), 4000);
@@ -238,9 +334,22 @@ void MainWindow::onFavAlbumsLoaded(const QJsonObject &result)
void MainWindow::onFavArtistsLoaded(const QJsonObject &result)
{
// Always cache fav artist IDs (needed by the artist page fav button)
m_favArtistIds.clear();
const QJsonArray items = result["items"].toArray();
for (const QJsonValue &v : items) {
const qint64 id = static_cast<qint64>(v.toObject()["id"].toDouble());
if (id > 0) m_favArtistIds.insert(id);
}
m_content->setFavArtistIds(m_favArtistIds);
// Only navigate to the fav artists page if the user explicitly requested it
if (m_showFavArtistsOnLoad) {
m_showFavArtistsOnLoad = false;
m_content->showFavArtists(result);
statusBar()->showMessage(
tr("%1 favorite artists").arg(result["total"].toInt()), 4000);
}
}
void MainWindow::onAlbumLoaded(const QJsonObject &album)
@@ -253,8 +362,14 @@ void MainWindow::onAlbumLoaded(const QJsonObject &album)
void MainWindow::onArtistLoaded(const QJsonObject &artist)
{
m_content->showArtist(artist);
// Fire release requests only after the artist page is shown — avoids the
// race where a fast-responding release request arrives before setArtist()
// clears the sections, causing setArtist() to wipe out the data.
const qint64 artistId = static_cast<qint64>(artist["id"].toDouble());
for (const char *type : {"album", "epSingle", "live", "compilation"})
m_backend->getArtistReleases(artistId, QString::fromLatin1(type));
statusBar()->showMessage(
tr("Artist: %1").arg(artist["name"].toString()), 4000);
tr("Artist: %1").arg(artist["name"].toObject()["display"].toString()), 4000);
}
void MainWindow::onPlaylistLoaded(const QJsonObject &playlist)
@@ -285,3 +400,21 @@ void MainWindow::onSearchToggled(bool visible)
{
m_sidePanel->setVisible(visible);
}
void MainWindow::onPlaylistCreated(const QJsonObject &playlist)
{
const QString name = playlist["name"].toString();
statusBar()->showMessage(tr("Playlist '%1' created").arg(name), 4000);
// Open the new playlist immediately
const qint64 id = static_cast<qint64>(playlist["id"].toDouble());
if (id > 0)
m_backend->getPlaylist(id);
}
void MainWindow::onUserPlaylistsChanged(const QVector<QPair<qint64, QString>> &playlists)
{
m_userPlaylists = playlists;
m_content->tracksList()->setUserPlaylists(playlists);
m_sidePanel->searchTab()->setUserPlaylists(playlists);
}

View File

@@ -8,10 +8,16 @@
#include "view/queuepanel.hpp"
#include "view/sidepanel/view.hpp"
#include "list/library.hpp"
#include "scrobbler/lastfm.hpp"
#include <QMainWindow>
#include <QDockWidget>
#include <QJsonObject>
#include <QJsonArray>
#include <QVector>
#include <QSet>
#include <QPair>
#include <QString>
class MainWindow : public QMainWindow
{
@@ -37,6 +43,8 @@ private slots:
void onSearchAlbumSelected(const QString &albumId);
void onSearchArtistSelected(qint64 artistId);
void onSearchToggled(bool visible);
void onPlaylistCreated(const QJsonObject &playlist);
void onUserPlaylistsChanged(const QVector<QPair<qint64, QString>> &playlists);
void showLoginDialog();
void showSettingsDialog();
@@ -44,6 +52,9 @@ private slots:
private:
QobuzBackend *m_backend = nullptr;
PlayQueue *m_queue = nullptr;
QVector<QPair<qint64, QString>> m_userPlaylists;
QSet<qint64> m_favArtistIds;
bool m_showFavArtistsOnLoad = false;
MainToolBar *m_toolBar = nullptr;
MainContent *m_content = nullptr;
List::Library *m_library = nullptr;
@@ -51,6 +62,7 @@ private:
QueuePanel *m_queuePanel = nullptr;
SidePanel::View *m_sidePanel = nullptr;
QDockWidget *m_libraryDock = nullptr;
LastFmScrobbler *m_scrobbler = nullptr;
void setupMenuBar();
void tryRestoreSession();

View File

@@ -17,17 +17,28 @@ void TrackListModel::setTracks(const QJsonArray &tracks,
m_tracks.clear();
m_tracks.reserve(tracks.size());
// Parse into a temporary list first so we can detect multi-disc
QVector<TrackItem> parsed;
parsed.reserve(tracks.size());
int seq = 1;
for (const QJsonValue &v : tracks) {
const QJsonObject t = v.toObject();
TrackItem item;
item.id = static_cast<qint64>(t["id"].toDouble());
item.title = t["title"].toString();
item.playlistTrackId = static_cast<qint64>(t["playlist_track_id"].toDouble());
item.discNumber = t["media_number"].toInt(1);
item.duration = static_cast<qint64>(t["duration"].toDouble());
item.hiRes = t["hires_streamable"].toBool();
item.streamable = t["streamable"].toBool(true);
item.hiRes = t["hires_streamable"].toBool();
item.raw = t;
// Combine title + version ("Melody" + "Vocal Remix" → "Melody (Vocal Remix)")
const QString base = t["title"].toString();
const QString version = t["version"].toString().trimmed();
item.title = version.isEmpty() ? base
: base + QStringLiteral(" (") + version + QLatin1Char(')');
if (useSequential) {
item.number = seq++;
} else if (usePosition) {
@@ -40,24 +51,61 @@ void TrackListModel::setTracks(const QJsonArray &tracks,
const QJsonObject performer = t["performer"].toObject();
item.artist = performer["name"].toString();
if (item.artist.isEmpty())
item.artist = t["album"].toObject()["artist"].toObject()["name"].toString();
if (item.artist.isEmpty()) {
// album.artist.name may be a plain string or {display:"..."} object
const QJsonValue n = t["album"].toObject()["artist"].toObject()["name"];
item.artist = n.isObject() ? n.toObject()["display"].toString() : n.toString();
}
if (item.artist.isEmpty()) {
// top_tracks format: artist.name.display
const QJsonValue n = t["artist"].toObject()["name"];
item.artist = n.isObject() ? n.toObject()["display"].toString() : n.toString();
}
const QJsonObject album = t["album"].toObject();
item.album = album["title"].toString();
item.albumId = album["id"].toString();
m_tracks.append(item);
parsed.append(item);
}
// Re-apply sort silently inside the reset (no layout signals needed here)
// Multi-disc only makes sense for album context (not playlists / fav / search)
int maxDisc = 1;
if (!usePosition && !useSequential) {
for (const TrackItem &t : parsed)
maxDisc = qMax(maxDisc, t.discNumber);
}
m_hasMultipleDiscs = (maxDisc > 1);
if (m_hasMultipleDiscs) {
// Sort by disc then track number
std::stable_sort(parsed.begin(), parsed.end(), [](const TrackItem &a, const TrackItem &b) {
return a.discNumber != b.discNumber ? a.discNumber < b.discNumber
: a.number < b.number;
});
// Interleave disc header items
int currentDisc = -1;
for (const TrackItem &t : parsed) {
if (t.discNumber != currentDisc) {
TrackItem header;
header.isDiscHeader = true;
header.discNumber = t.discNumber;
header.title = tr("Disc %1").arg(t.discNumber);
m_tracks.append(header);
currentDisc = t.discNumber;
}
m_tracks.append(t);
}
} else {
m_tracks = parsed;
// Re-apply sort silently inside the reset
if (m_sortColumn >= 0)
sortData(m_sortColumn, m_sortOrder);
}
endResetModel();
// Tell external listeners the sorted order is ready (e.g. PlayQueue sync)
if (m_sortColumn >= 0)
if (!m_hasMultipleDiscs && m_sortColumn >= 0)
emit sortApplied();
}
@@ -68,6 +116,44 @@ void TrackListModel::clear()
endResetModel();
}
void TrackListModel::removeTrack(int row)
{
if (row < 0 || row >= m_tracks.size()) return;
beginRemoveRows({}, row, row);
m_tracks.removeAt(row);
endRemoveRows();
}
void TrackListModel::setFavIds(const QSet<qint64> &ids)
{
m_favIds = ids;
if (!m_tracks.isEmpty())
emit dataChanged(index(0, ColTitle), index(rowCount() - 1, ColTitle),
{Qt::DecorationRole});
}
void TrackListModel::addFavId(qint64 id)
{
m_favIds.insert(id);
for (int r = 0; r < m_tracks.size(); ++r) {
if (m_tracks[r].id == id) {
const auto idx = index(r, ColTitle);
emit dataChanged(idx, idx, {Qt::DecorationRole});
}
}
}
void TrackListModel::removeFavId(qint64 id)
{
m_favIds.remove(id);
for (int r = 0; r < m_tracks.size(); ++r) {
if (m_tracks[r].id == id) {
const auto idx = index(r, ColTitle);
emit dataChanged(idx, idx, {Qt::DecorationRole});
}
}
}
void TrackListModel::setPlayingId(qint64 id)
{
m_playingId = id;
@@ -76,6 +162,23 @@ void TrackListModel::setPlayingId(qint64 id)
{Qt::FontRole, Qt::DecorationRole});
}
Qt::ItemFlags TrackListModel::flags(const QModelIndex &index) const
{
if (!index.isValid() || index.row() >= m_tracks.size())
return Qt::NoItemFlags;
if (m_tracks.at(index.row()).isDiscHeader)
return Qt::ItemIsEnabled;
return Qt::ItemIsEnabled | Qt::ItemIsSelectable;
}
QVector<int> TrackListModel::discHeaderRows() const
{
QVector<int> rows;
for (int i = 0; i < m_tracks.size(); ++i)
if (m_tracks[i].isDiscHeader) rows.append(i);
return rows;
}
int TrackListModel::rowCount(const QModelIndex &parent) const
{
return parent.isValid() ? 0 : m_tracks.size();
@@ -92,6 +195,19 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const
return {};
const TrackItem &t = m_tracks.at(index.row());
// Disc header rows: styled separator spanning all columns via setFirstColumnSpanned
if (t.isDiscHeader) {
if (role == Qt::DisplayRole && index.column() == ColNumber)
return t.title;
if (role == Qt::FontRole) {
QFont f; f.setBold(true); return f;
}
if (role == Qt::ForegroundRole)
return QColor(0xFF, 0xB2, 0x32);
return {};
}
const bool isPlaying = (t.id == m_playingId && m_playingId != 0);
if (role == Qt::DisplayRole) {
@@ -111,17 +227,22 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const
}
if (role == Qt::ForegroundRole) {
if (!t.streamable) return QColor(Qt::gray);
if (isPlaying) return QColor(0x1d, 0xb9, 0x54); // Qobuz green
if (!t.streamable) return QColor(0x55, 0x55, 0x55);
if (isPlaying) return QColor(0xFF, 0xB2, 0x32); // Qobuz orange
}
if (role == Qt::DecorationRole && index.column() == ColNumber && isPlaying) {
return QIcon(QStringLiteral(":/res/icons/media-track-show-active.svg"));
}
if (role == Qt::DecorationRole && index.column() == ColTitle && m_favIds.contains(t.id)) {
return QIcon(QStringLiteral(":/res/icons/starred-symbolic.svg"));
}
if (role == TrackIdRole) return t.id;
if (role == TrackJsonRole) return t.raw;
if (role == HiResRole) return t.hiRes;
if (role == PlaylistTrackIdRole) return t.playlistTrackId;
return {};
}
@@ -169,7 +290,8 @@ void TrackListModel::sort(int column, Qt::SortOrder order)
m_sortColumn = column;
m_sortOrder = order;
if (m_tracks.isEmpty()) return;
// Multi-disc albums keep their disc-ordered layout; don't re-sort
if (m_hasMultipleDiscs || m_tracks.isEmpty()) return;
emit layoutAboutToBeChanged();
sortData(column, order);
@@ -180,7 +302,8 @@ void TrackListModel::sort(int column, Qt::SortOrder order)
QString TrackListModel::formatDuration(qint64 secs)
{
const int m = static_cast<int>(secs / 60);
const int s = static_cast<int>(secs % 60);
if (secs < 0) secs = 0;
const qint64 m = secs / 60;
const qint64 s = secs % 60;
return QStringLiteral("%1:%2").arg(m).arg(s, 2, 10, QLatin1Char('0'));
}

View File

@@ -4,11 +4,15 @@
#include <QJsonArray>
#include <QJsonObject>
#include <QVector>
#include <QSet>
#include <QFont>
struct TrackItem {
qint64 id = 0;
qint64 playlistTrackId = 0;
int number = 0;
int discNumber = 1;
bool isDiscHeader = false;
QString title;
QString artist;
QString album;
@@ -37,6 +41,7 @@ public:
TrackIdRole = Qt::UserRole + 1,
TrackJsonRole = Qt::UserRole + 2,
HiResRole = Qt::UserRole + 3,
PlaylistTrackIdRole = Qt::UserRole + 4,
};
explicit TrackListModel(QObject *parent = nullptr);
@@ -50,14 +55,27 @@ public:
void setPlayingId(qint64 id);
qint64 playingId() const { return m_playingId; }
void setFavIds(const QSet<qint64> &ids);
void addFavId(qint64 id);
void removeFavId(qint64 id);
bool isFav(qint64 id) const { return m_favIds.contains(id); }
bool hasMultipleDiscs() const { return m_hasMultipleDiscs; }
QVector<int> discHeaderRows() const;
Qt::ItemFlags flags(const QModelIndex &index) const override;
/// Optimistically remove a row (e.g. after deleting from playlist).
void removeTrack(int row);
const TrackItem &trackAt(int row) const { return m_tracks.at(row); }
// Returns the current (possibly sorted) raw JSON objects in display order.
// Returns the current (possibly sorted) raw JSON objects in display order, skipping disc headers.
QJsonArray currentTracksJson() const
{
QJsonArray out;
for (const auto &t : m_tracks)
out.append(t.raw);
if (!t.isDiscHeader) out.append(t.raw);
return out;
}
@@ -76,7 +94,9 @@ signals:
private:
QVector<TrackItem> m_tracks;
QSet<qint64> m_favIds;
qint64 m_playingId = 0;
bool m_hasMultipleDiscs = false;
int m_sortColumn = -1;
Qt::SortOrder m_sortOrder = Qt::AscendingOrder;

View File

@@ -25,14 +25,27 @@ public:
{
m_queue.clear();
m_playNext.clear();
for (const auto &v : tracks)
m_queue.append(v.toObject());
if (m_shuffle) {
shuffleQueue(startIndex);
// shuffleQueue moves the start track to index 0 and sets m_index = 0
} else {
m_index = qBound(0, startIndex, m_queue.size() - 1);
// Only queue streamable tracks; find the filtered index for startIndex
int filteredStart = 0;
int filteredIdx = 0;
bool found = false;
for (int orig = 0; orig < tracks.size(); ++orig) {
const QJsonObject t = tracks[orig].toObject();
if (!t["streamable"].toBool(true))
continue;
if (!found && orig >= startIndex) {
filteredStart = filteredIdx;
found = true;
}
m_queue.append(t);
++filteredIdx;
}
m_index = qBound(0, filteredStart, qMax(0, m_queue.size() - 1));
if (m_shuffle)
shuffleQueue(m_index);
emit queueChanged();
}
@@ -41,8 +54,11 @@ public:
void reorderContext(const QJsonArray &tracks, qint64 currentId)
{
m_queue.clear();
for (const auto &v : tracks)
m_queue.append(v.toObject());
for (const auto &v : tracks) {
const QJsonObject t = v.toObject();
if (t["streamable"].toBool(true))
m_queue.append(t);
}
m_index = 0;
for (int i = 0; i < m_queue.size(); ++i) {
@@ -91,6 +107,14 @@ public:
emit queueChanged();
}
/// Shuffle the current queue once without changing the global shuffle flag.
void shuffleNow()
{
if (m_queue.isEmpty()) return;
shuffleQueue(m_index);
emit queueChanged();
}
// ---- Play-next prepend queue (like "Add to queue" ----
void addToQueue(const QJsonObject &track)
@@ -184,6 +208,39 @@ public:
int totalSize() const { return m_playNext.size() + m_queue.size(); }
int currentIndex() const { return m_index; }
/// Skip to upcoming[upcomingIndex]: removes everything before it, pops and returns it.
QJsonObject skipToUpcoming(int upcomingIndex)
{
// Remove items 0..upcomingIndex-1 from the front of upcoming
for (int i = 0; i < upcomingIndex; ++i) {
if (!m_playNext.isEmpty())
m_playNext.removeFirst();
else if (m_index + 1 < m_queue.size())
++m_index;
}
// Pop and return the target (now at upcoming[0])
if (!m_playNext.isEmpty()) {
const QJsonObject t = m_playNext.takeFirst();
emit queueChanged();
return t;
}
if (m_index + 1 < m_queue.size()) {
++m_index;
emit queueChanged();
return m_queue.at(m_index);
}
emit queueChanged();
return {};
}
/// Replace the upcoming list with a new order (used after drag-reorder in UI).
void setUpcomingOrder(const QVector<QJsonObject> &newOrder)
{
m_playNext = newOrder;
m_queue.resize(m_index + 1); // drop old main-queue tail
emit queueChanged();
}
/// Move an upcoming item (by its index in upcomingTracks()) to the front of playNext.
void moveUpcomingToTop(int upcomingIndex)
{

179
src/scrobbler/lastfm.hpp Normal file
View File

@@ -0,0 +1,179 @@
#pragma once
#include "../util/settings.hpp"
#include <QObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUrlQuery>
#include <QUrl>
#include <QCryptographicHash>
#include <QDateTime>
#include <QJsonDocument>
#include <QJsonObject>
#include <QMap>
#include <functional>
/// Handles Last.fm now-playing updates and scrobbling.
/// Connect to QobuzBackend signals and call onTrackStarted / onPositionChanged / onTrackFinished.
class LastFmScrobbler : public QObject
{
Q_OBJECT
public:
explicit LastFmScrobbler(QObject *parent = nullptr)
: QObject(parent)
, m_nam(new QNetworkAccessManager(this))
{}
/// Authenticate via auth.getMobileSession and store the session key.
/// callback(success, errorMessage)
void authenticate(const QString &username, const QString &password,
std::function<void(bool, const QString &)> callback)
{
const QString apiKey = AppSettings::instance().lastFmApiKey();
const QString apiSecret = AppSettings::instance().lastFmApiSecret();
if (apiKey.isEmpty() || apiSecret.isEmpty()) {
callback(false, tr("API key or secret is not set."));
return;
}
QMap<QString,QString> params;
params["method"] = QStringLiteral("auth.getMobileSession");
params["api_key"] = apiKey;
params["username"] = username;
params["password"] = password;
params["api_sig"] = buildSig(params, apiSecret);
params["format"] = QStringLiteral("json");
auto *reply = m_nam->post(apiRequest(), encodeBody(params));
connect(reply, &QNetworkReply::finished, this, [reply, callback] {
reply->deleteLater();
const QJsonObject obj = QJsonDocument::fromJson(reply->readAll()).object();
const QString key = obj["session"].toObject()["key"].toString();
if (!key.isEmpty()) {
AppSettings::instance().setLastFmSessionKey(key);
callback(true, {});
} else {
callback(false, obj["message"].toString(tr("Authentication failed.")));
}
});
}
bool isEnabled() const
{
return AppSettings::instance().lastFmEnabled()
&& !AppSettings::instance().lastFmApiKey().isEmpty()
&& !AppSettings::instance().lastFmApiSecret().isEmpty()
&& !AppSettings::instance().lastFmSessionKey().isEmpty();
}
public slots:
void onTrackStarted(const QJsonObject &track)
{
m_title = track["title"].toString();
m_artist = track["performer"].toObject()["name"].toString();
if (m_artist.isEmpty())
m_artist = track["album"].toObject()["artist"].toObject()["name"].toString();
m_album = track["album"].toObject()["title"].toString();
m_duration = static_cast<qint64>(track["duration"].toDouble());
m_startTime = QDateTime::currentSecsSinceEpoch();
m_playedSecs = 0;
m_scrobbled = false;
if (!isEnabled() || m_title.isEmpty() || m_duration < 30) return;
updateNowPlaying();
}
void onPositionChanged(quint64 positionSecs, quint64 /*duration*/)
{
m_playedSecs = positionSecs;
if (!isEnabled() || m_scrobbled || m_title.isEmpty() || m_duration < 30) return;
// Scrobble after 50% or 240 seconds played, whichever comes first, min 30 seconds.
const quint64 threshold = static_cast<quint64>(qMin((qint64)240, m_duration / 2));
if (positionSecs >= 30 && positionSecs >= threshold)
scrobble();
}
void onTrackFinished()
{
if (!isEnabled() || m_scrobbled || m_title.isEmpty() || m_duration < 30) return;
if (m_playedSecs >= 30) scrobble();
}
private:
QNetworkAccessManager *m_nam = nullptr;
QString m_title;
QString m_artist;
QString m_album;
qint64 m_duration = 0;
qint64 m_startTime = 0;
quint64 m_playedSecs = 0;
bool m_scrobbled = false;
void updateNowPlaying()
{
QMap<QString,QString> params;
params["method"] = QStringLiteral("track.updateNowPlaying");
params["api_key"] = AppSettings::instance().lastFmApiKey();
params["sk"] = AppSettings::instance().lastFmSessionKey();
params["artist"] = m_artist;
params["track"] = m_title;
params["album"] = m_album;
params["duration"] = QString::number(m_duration);
params["api_sig"] = buildSig(params, AppSettings::instance().lastFmApiSecret());
params["format"] = QStringLiteral("json");
auto *reply = m_nam->post(apiRequest(), encodeBody(params));
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
}
void scrobble()
{
m_scrobbled = true;
QMap<QString,QString> params;
params["method"] = QStringLiteral("track.scrobble");
params["api_key"] = AppSettings::instance().lastFmApiKey();
params["sk"] = AppSettings::instance().lastFmSessionKey();
params["artist"] = m_artist;
params["track"] = m_title;
params["album"] = m_album;
params["timestamp"] = QString::number(m_startTime);
params["duration"] = QString::number(m_duration);
params["api_sig"] = buildSig(params, AppSettings::instance().lastFmApiSecret());
params["format"] = QStringLiteral("json");
auto *reply = m_nam->post(apiRequest(), encodeBody(params));
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
}
/// Build the Last.fm API signature: sort params, concatenate key+value, append secret, md5.
static QString buildSig(const QMap<QString,QString> &params, const QString &secret)
{
QString s;
for (auto it = params.constBegin(); it != params.constEnd(); ++it) {
if (it.key() != "format" && it.key() != "api_sig")
s += it.key() + it.value();
}
s += secret;
return QCryptographicHash::hash(s.toUtf8(), QCryptographicHash::Md5).toHex();
}
static QNetworkRequest apiRequest()
{
QNetworkRequest req(QUrl(QStringLiteral("https://ws.audioscrobbler.com/2.0/")));
req.setHeader(QNetworkRequest::ContentTypeHeader,
QStringLiteral("application/x-www-form-urlencoded"));
return req;
}
static QByteArray encodeBody(const QMap<QString,QString> &params)
{
QUrlQuery q;
for (auto it = params.constBegin(); it != params.constEnd(); ++it)
q.addQueryItem(it.key(), it.value());
return q.toString(QUrl::FullyEncoded).toUtf8();
}
};

View File

@@ -21,6 +21,9 @@ public:
QString displayName() const { return m_settings.value("user/display_name").toString(); }
void setDisplayName(const QString &n) { m_settings.setValue("user/display_name", n); }
qint64 userId() const { return m_settings.value("user/id", 0LL).toLongLong(); }
void setUserId(qint64 id) { m_settings.setValue("user/id", id); }
// 5 = MP3, 6 = CD, 7 = HiRes96, 27 = HiRes192
int preferredFormat() const { return m_settings.value("playback/format", 6).toInt(); }
void setPreferredFormat(int f) { m_settings.setValue("playback/format", f); }
@@ -31,6 +34,26 @@ public:
bool rememberLogin() const { return m_settings.value("auth/remember", true).toBool(); }
void setRememberLogin(bool r) { m_settings.setValue("auth/remember", r); }
// --- Playback extras ---
bool replayGainEnabled() const { return m_settings.value("playback/replaygain", false).toBool(); }
void setReplayGainEnabled(bool v) { m_settings.setValue("playback/replaygain", v); }
bool gaplessEnabled() const { return m_settings.value("playback/gapless", false).toBool(); }
void setGaplessEnabled(bool v) { m_settings.setValue("playback/gapless", v); }
// --- Last.fm ---
bool lastFmEnabled() const { return m_settings.value("lastfm/enabled", false).toBool(); }
void setLastFmEnabled(bool v) { m_settings.setValue("lastfm/enabled", v); }
QString lastFmApiKey() const { return m_settings.value("lastfm/api_key").toString(); }
void setLastFmApiKey(const QString &v) { m_settings.setValue("lastfm/api_key", v); }
QString lastFmApiSecret() const { return m_settings.value("lastfm/api_secret").toString(); }
void setLastFmApiSecret(const QString &v) { m_settings.setValue("lastfm/api_secret", v); }
QString lastFmSessionKey() const { return m_settings.value("lastfm/session_key").toString(); }
void setLastFmSessionKey(const QString &v){ m_settings.setValue("lastfm/session_key", v); }
private:
AppSettings() : m_settings("qobuz-qt", "qobuz-qt") {}
QSettings m_settings;

85
src/util/trackinfo.hpp Normal file
View File

@@ -0,0 +1,85 @@
#pragma once
#include <QDialog>
#include <QFormLayout>
#include <QDialogButtonBox>
#include <QLabel>
#include <QJsonObject>
#include <QWidget>
namespace TrackInfoDialog
{
inline void show(const QJsonObject &track, QWidget *parent)
{
auto *dlg = new QDialog(parent);
dlg->setWindowTitle(QObject::tr("Track Info"));
dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->setMinimumWidth(360);
auto *form = new QFormLayout(dlg);
auto addRow = [&](const QString &label, const QString &value) {
if (value.isEmpty()) return;
auto *val = new QLabel(value, dlg);
val->setTextInteractionFlags(Qt::TextSelectableByMouse);
val->setWordWrap(true);
form->addRow(QStringLiteral("<b>%1</b>").arg(label), val);
};
const QString title = track["title"].toString();
const QString version = track["version"].toString().trimmed();
addRow(QObject::tr("Title"),
version.isEmpty() ? title : title + QStringLiteral(" (%1)").arg(version));
addRow(QObject::tr("Performer"), track["performer"].toObject()["name"].toString());
const QJsonObject composer = track["composer"].toObject();
if (!composer.isEmpty())
addRow(QObject::tr("Composer"), composer["name"].toString());
const QJsonObject album = track["album"].toObject();
addRow(QObject::tr("Album"), album["title"].toString());
addRow(QObject::tr("Album artist"), album["artist"].toObject()["name"].toString());
const int trackNum = track["track_number"].toInt();
const int discNum = track["media_number"].toInt();
if (trackNum > 0) {
const QString pos = discNum > 1
? QStringLiteral("%1-%2").arg(discNum).arg(trackNum)
: QString::number(trackNum);
addRow(QObject::tr("Track #"), pos);
}
const qint64 dur = static_cast<qint64>(track["duration"].toDouble());
if (dur > 0) {
const int m = static_cast<int>(dur / 60);
const int s = static_cast<int>(dur % 60);
addRow(QObject::tr("Duration"),
QStringLiteral("%1:%2").arg(m).arg(s, 2, 10, QLatin1Char('0')));
}
const int bitDepth = track["maximum_bit_depth"].toInt();
const double sampleRate = track["maximum_sampling_rate"].toDouble();
if (bitDepth > 0 && sampleRate > 0) {
addRow(QObject::tr("Quality"),
QStringLiteral("%1-bit / %2 kHz").arg(bitDepth).arg(sampleRate, 0, 'f', 1));
} else if (bitDepth > 0) {
addRow(QObject::tr("Bit depth"), QStringLiteral("%1-bit").arg(bitDepth));
}
const bool hiRes = track["hires_streamable"].toBool() || track["hires"].toBool();
addRow(QObject::tr("Hi-Res"), hiRes ? QObject::tr("Yes") : QObject::tr("No"));
const bool streamable = track["streamable"].toBool(true);
if (!streamable)
addRow(QObject::tr("Streamable"), QObject::tr("No"));
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, dlg);
form->addRow(buttons);
QObject::connect(buttons, &QDialogButtonBox::rejected, dlg, &QDialog::close);
dlg->show();
}
} // namespace TrackInfoDialog

View File

@@ -3,11 +3,13 @@
#include <QTreeWidget>
#include <QTreeWidgetItem>
#include <QHeaderView>
#include <QFont>
#include <QJsonObject>
#include <QJsonArray>
/// A simple list of albums (used for fav albums and artist detail pages).
/// Double-clicking an item emits albumSelected(albumId).
/// Column 0 shows a small gold "H" for hi-res streamable albums.
class AlbumListView : public QTreeWidget
{
Q_OBJECT
@@ -15,22 +17,23 @@ class AlbumListView : public QTreeWidget
public:
explicit AlbumListView(QWidget *parent = nullptr) : QTreeWidget(parent)
{
setColumnCount(4);
setHeaderLabels({tr("Title"), tr("Artist"), tr("Year"), tr("Tracks")});
setColumnCount(5);
setHeaderLabels({tr(""), tr("Title"), tr("Artist"), tr("Year"), tr("Tracks")});
setRootIsDecorated(false);
setAlternatingRowColors(true);
setSelectionBehavior(QAbstractItemView::SelectRows);
setSortingEnabled(true);
header()->setStretchLastSection(false);
header()->setSectionResizeMode(0, QHeaderView::Stretch);
header()->setSectionResizeMode(0, QHeaderView::ResizeToContents); // H column
header()->setSectionResizeMode(1, QHeaderView::Stretch);
header()->setSectionResizeMode(2, QHeaderView::ResizeToContents);
header()->setSectionResizeMode(2, QHeaderView::Stretch);
header()->setSectionResizeMode(3, QHeaderView::ResizeToContents);
header()->setSectionResizeMode(4, QHeaderView::ResizeToContents);
connect(this, &QTreeWidget::itemDoubleClicked,
this, [this](QTreeWidgetItem *item, int) {
const QString id = item->data(0, Qt::UserRole).toString();
const QString id = item->data(1, Qt::UserRole).toString();
if (!id.isEmpty()) emit albumSelected(id);
});
}
@@ -38,21 +41,62 @@ public:
void setAlbums(const QJsonArray &albums)
{
clear();
addAlbums(albums);
}
/// Configure for artist page: hide Artist column, set fixed column widths
/// that match the Popular Tracks list for perfect vertical alignment.
void setArtistPageMode()
{
setColumnHidden(2, true); // Artist — redundant on artist page
header()->setSectionResizeMode(0, QHeaderView::Fixed);
header()->setSectionResizeMode(1, QHeaderView::Stretch);
header()->setSectionResizeMode(3, QHeaderView::Fixed);
header()->setSectionResizeMode(4, QHeaderView::Fixed);
header()->resizeSection(0, 40);
header()->resizeSection(3, 120);
header()->resizeSection(4, 70);
}
void addAlbums(const QJsonArray &albums)
{
QFont hiResFont;
hiResFont.setBold(true);
hiResFont.setPointSizeF(hiResFont.pointSizeF() * 0.85);
for (const auto &v : albums) {
const QJsonObject a = v.toObject();
const QString id = a["id"].toString();
const QString title = a["title"].toString();
const QString artist = a["artist"].toObject()["name"].toString();
const QString base = a["title"].toString();
const QString ver = a["version"].toString().trimmed();
const QString title = ver.isEmpty() ? base : base + QStringLiteral(" (") + ver + QLatin1Char(')');
const QJsonValue artistNameVal = a["artist"].toObject()["name"];
const QString artist = artistNameVal.isObject()
? artistNameVal.toObject()["display"].toString()
: artistNameVal.toString();
const QString date = a["release_date_original"].toString();
const QString year = date.left(4);
const QString year = date.isEmpty()
? a["dates"].toObject()["original"].toString().left(4)
: date.left(4);
const int tracks = a["tracks_count"].toInt();
const bool hiRes = a["hires_streamable"].toBool()
|| a["rights"].toObject()["hires_streamable"].toBool();
auto *item = new QTreeWidgetItem(this);
item->setText(0, title);
item->setText(1, artist);
item->setText(2, year);
item->setText(3, tracks > 0 ? QString::number(tracks) : QString());
item->setData(0, Qt::UserRole, id);
if (hiRes) {
item->setText(0, QStringLiteral("H"));
item->setForeground(0, QColor(QStringLiteral("#FFB232")));
item->setFont(0, hiResFont);
item->setTextAlignment(0, Qt::AlignCenter);
}
item->setText(1, title);
item->setText(2, artist);
item->setText(3, year);
item->setText(4, tracks > 0 ? QString::number(tracks) : QString());
item->setData(1, Qt::UserRole, id);
}
}

427
src/view/artistview.cpp Normal file
View File

@@ -0,0 +1,427 @@
#include "artistview.hpp"
#include "albumlistview.hpp"
#include "../model/tracklistmodel.hpp"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QScrollArea>
#include <QHeaderView>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QPixmap>
#include <QUrl>
#include <QFont>
#include <QRegularExpression>
// Shared button style (mirrors TrackContextHeader)
static const QString kBtnBase = QStringLiteral(
"QPushButton { padding: 5px 16px; border-radius: 4px; font-weight: bold; }"
);
// Section-toggle style: flat QPushButton, truly left-aligned
static const QString kToggleStyle = QStringLiteral(
"QPushButton { text-align: left; font-weight: bold; font-size: 13px;"
" padding: 6px 8px; border: none; border-bottom: 1px solid #333;"
" background: transparent; }"
"QPushButton:hover { background: #1e1e1e; }"
);
// ---------------------------------------------------------------------------
// ArtistSection
// ---------------------------------------------------------------------------
ArtistSection::ArtistSection(const QString &title, const QString &releaseType, QWidget *parent)
: QWidget(parent)
, m_baseTitle(title)
, m_releaseType(releaseType)
{
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
m_toggle = new QPushButton(this);
m_toggle->setCheckable(true);
m_toggle->setChecked(true);
m_toggle->setFlat(true);
m_toggle->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
m_toggle->setStyleSheet(kToggleStyle);
layout->addWidget(m_toggle);
m_list = new AlbumListView(this);
layout->addWidget(m_list);
connect(m_toggle, &QPushButton::toggled, this, [this](bool checked) {
m_list->setVisible(checked);
updateToggleText();
});
connect(m_list, &AlbumListView::albumSelected, this, &ArtistSection::albumSelected);
updateToggleText();
}
void ArtistSection::setAlbums(const QJsonArray &albums)
{
m_list->setAlbums(albums);
updateToggleText();
}
bool ArtistSection::isEmpty() const
{
return m_list->topLevelItemCount() == 0;
}
QStringList ArtistSection::albumIds() const
{
QStringList ids;
for (int i = 0; i < m_list->topLevelItemCount(); ++i) {
const QString id = m_list->topLevelItem(i)->data(1, Qt::UserRole).toString();
if (!id.isEmpty())
ids.append(id);
}
return ids;
}
void ArtistSection::setArtistPageMode()
{
m_list->setArtistPageMode();
}
void ArtistSection::updateToggleText()
{
const int count = m_list->topLevelItemCount();
const QString arrow = m_toggle->isChecked() ? QStringLiteral("") : QStringLiteral("");
const QString text = count > 0
? QStringLiteral("%1%2 (%3)").arg(arrow, m_baseTitle).arg(count)
: arrow + m_baseTitle;
m_toggle->setText(text);
}
// ---------------------------------------------------------------------------
// ArtistView
// ---------------------------------------------------------------------------
ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
: QWidget(parent)
, m_backend(backend)
, m_queue(queue)
{
auto *outerLayout = new QVBoxLayout(this);
outerLayout->setContentsMargins(0, 0, 0, 0);
outerLayout->setSpacing(0);
// --- Artist header (same structure as TrackContextHeader) ---
auto *header = new QWidget(this);
header->setFixedHeight(148);
auto *hlay = new QHBoxLayout(header);
hlay->setContentsMargins(12, 8, 12, 8);
hlay->setSpacing(14);
m_artLabel = new QLabel(header);
m_artLabel->setFixedSize(120, 120);
m_artLabel->setScaledContents(true);
m_artLabel->setAlignment(Qt::AlignCenter);
m_artLabel->setStyleSheet(QStringLiteral("background: #1a1a1a; border-radius: 4px;"));
hlay->addWidget(m_artLabel, 0, Qt::AlignVCenter);
auto *info = new QWidget(header);
auto *vlay = new QVBoxLayout(info);
vlay->setContentsMargins(0, 0, 0, 0);
vlay->setSpacing(4);
m_nameLabel = new QLabel(info);
QFont f = m_nameLabel->font();
f.setPointSize(f.pointSize() + 5);
f.setBold(true);
m_nameLabel->setFont(f);
vlay->addWidget(m_nameLabel);
m_bioEdit = new QTextEdit(info);
m_bioEdit->setReadOnly(true);
m_bioEdit->setFrameShape(QFrame::NoFrame);
m_bioEdit->setMaximumHeight(56);
m_bioEdit->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
m_bioEdit->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
vlay->addWidget(m_bioEdit);
auto *btnRow = new QHBoxLayout;
btnRow->setSpacing(8);
btnRow->setContentsMargins(0, 4, 0, 0);
static const QString kOutlineBtn = kBtnBase +
QStringLiteral("QPushButton { background: #2a2a2a; color: #FFB232; border: 1px solid #FFB232; }"
"QPushButton:pressed { background: #333; }");
m_playBtn = new QPushButton(tr("▶ Play"), info);
m_playBtn->setStyleSheet(kBtnBase +
QStringLiteral("QPushButton { background: #FFB232; color: #000; }"
"QPushButton:pressed { background: #e09e28; }"));
m_shuffleTopBtn = new QPushButton(tr("⇄ Shuffle"), info);
m_shuffleTopBtn->setStyleSheet(kOutlineBtn);
m_shuffleBtn = new QPushButton(tr("⇄ Shuffle All"), info);
m_shuffleBtn->setStyleSheet(kOutlineBtn);
m_favBtn = new QPushButton(tr("♡ Favourite"), info);
m_favBtn->setStyleSheet(kBtnBase +
QStringLiteral("QPushButton { background: #2a2a2a; color: #ccc; border: 1px solid #555; }"
"QPushButton:pressed { background: #333; }"));
btnRow->addWidget(m_playBtn);
btnRow->addWidget(m_shuffleTopBtn);
btnRow->addWidget(m_shuffleBtn);
btnRow->addWidget(m_favBtn);
btnRow->addStretch();
vlay->addLayout(btnRow);
vlay->addStretch(1);
hlay->addWidget(info, 1);
outerLayout->addWidget(header);
// --- Network manager for portrait ---
m_nam = new QNetworkAccessManager(this);
QObject::connect(m_nam, &QNetworkAccessManager::finished,
this, [this](QNetworkReply *reply) {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) return;
QPixmap pix;
if (pix.loadFromData(reply->readAll()))
m_artLabel->setPixmap(pix);
});
// --- Scrollable sections area ---
auto *scroll = new QScrollArea(this);
scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame);
auto *content = new QWidget(scroll);
auto *sectLayout = new QVBoxLayout(content);
sectLayout->setContentsMargins(0, 0, 0, 0);
sectLayout->setSpacing(0);
// Popular Tracks section — same toggle style as release sections
m_topTracksSection = new QWidget(content);
auto *ttLayout = new QVBoxLayout(m_topTracksSection);
ttLayout->setContentsMargins(0, 0, 0, 0);
ttLayout->setSpacing(0);
m_topTracksToggle = new QPushButton(m_topTracksSection);
m_topTracksToggle->setCheckable(true);
m_topTracksToggle->setChecked(true);
m_topTracksToggle->setFlat(true);
m_topTracksToggle->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
m_topTracksToggle->setStyleSheet(kToggleStyle);
ttLayout->addWidget(m_topTracksToggle);
m_topTracks = new List::Tracks(backend, queue, m_topTracksSection);
m_topTracks->setMaximumHeight(320);
// Artist page column layout: hide Artist & Album, match album-section widths
m_topTracks->setColumnHidden(TrackListModel::ColArtist, true);
m_topTracks->setColumnHidden(TrackListModel::ColAlbum, true);
m_topTracks->header()->setSectionResizeMode(TrackListModel::ColNumber, QHeaderView::Fixed);
m_topTracks->header()->setSectionResizeMode(TrackListModel::ColTitle, QHeaderView::Stretch);
m_topTracks->header()->setSectionResizeMode(TrackListModel::ColDuration, QHeaderView::Fixed);
m_topTracks->header()->resizeSection(TrackListModel::ColNumber, 40);
m_topTracks->header()->resizeSection(TrackListModel::ColDuration, 70);
ttLayout->addWidget(m_topTracks);
connect(m_topTracksToggle, &QPushButton::toggled, m_topTracks, &QWidget::setVisible);
connect(m_topTracks, &List::Tracks::playTrackRequested, this, &ArtistView::playTrackRequested);
sectLayout->addWidget(m_topTracksSection);
// Release sections
m_secAlbums = new ArtistSection(tr("Albums"), QStringLiteral("album"), content);
m_secEps = new ArtistSection(tr("Singles & EPs"), QStringLiteral("epSingle"), content);
m_secLive = new ArtistSection(tr("Live"), QStringLiteral("live"), content);
m_secCompilations = new ArtistSection(tr("Compilations"), QStringLiteral("compilation"), content);
m_secOther = new ArtistSection(tr("Other"), QStringLiteral("other"), content);
// Uniform column layout: hide Artist column, match fixed widths across all sections
for (ArtistSection *sec : {m_secAlbums, m_secEps, m_secLive, m_secCompilations, m_secOther})
sec->setArtistPageMode();
sectLayout->addWidget(m_secAlbums);
sectLayout->addWidget(m_secEps);
sectLayout->addWidget(m_secLive);
sectLayout->addWidget(m_secCompilations);
sectLayout->addWidget(m_secOther);
sectLayout->addStretch();
scroll->setWidget(content);
outerLayout->addWidget(scroll, 1);
// Play / shuffle top tracks
connect(m_playBtn, &QPushButton::clicked, m_topTracks, [this] { m_topTracks->playAll(false); });
connect(m_shuffleTopBtn, &QPushButton::clicked, m_topTracks, [this] { m_topTracks->playAll(true); });
// Deep shuffle: fetch all album tracks, combine, shuffle, play
connect(m_shuffleBtn, &QPushButton::clicked, this, [this] {
const QStringList ids = allAlbumIds();
if (ids.isEmpty()) return;
m_shuffleBtn->setEnabled(false);
m_shuffleBtn->setText(tr("Loading…"));
m_backend->getAlbumsTracks(ids);
});
// Favourite button
connect(m_favBtn, &QPushButton::clicked, this, [this] {
if (m_artistId <= 0) return;
m_isFaved = !m_isFaved;
if (m_isFaved) {
m_backend->addFavArtist(m_artistId);
m_favArtistIds.insert(m_artistId);
} else {
m_backend->removeFavArtist(m_artistId);
m_favArtistIds.remove(m_artistId);
}
setFaved(m_isFaved);
});
// Album section connections
connect(m_secAlbums, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
connect(m_secEps, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
connect(m_secLive, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
connect(m_secCompilations, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
connect(m_secOther, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
}
void ArtistView::setArtist(const QJsonObject &artist)
{
m_artistId = static_cast<qint64>(artist["id"].toDouble());
setFaved(m_favArtistIds.contains(m_artistId));
m_nameLabel->setText(artist["name"].toObject()["display"].toString());
// Biography: strip HTML tags
const QString bioHtml = artist["biography"].toObject()["content"].toString();
if (!bioHtml.isEmpty()) {
QString plain = bioHtml;
plain.remove(QRegularExpression(QStringLiteral("<[^>]*>")));
plain.replace(QStringLiteral("&amp;"), QStringLiteral("&"));
plain.replace(QStringLiteral("&lt;"), QStringLiteral("<"));
plain.replace(QStringLiteral("&gt;"), QStringLiteral(">"));
plain.replace(QStringLiteral("&quot;"), QStringLiteral("\""));
plain.replace(QStringLiteral("&#39;"), QStringLiteral("'"));
plain.replace(QStringLiteral("&nbsp;"), QStringLiteral(" "));
plain = plain.trimmed();
m_bioEdit->setPlainText(plain);
m_bioEdit->setVisible(!plain.isEmpty());
} else {
m_bioEdit->setVisible(false);
}
// Artist portrait: images.portrait.hash + format → CDN URL
const QJsonObject portrait = artist["images"].toObject()["portrait"].toObject();
const QString hash = portrait["hash"].toString();
const QString format = portrait["format"].toString();
QString artUrl;
if (!hash.isEmpty()) {
artUrl = QStringLiteral("https://static.qobuz.com/images/artists/covers/large/%1.%2")
.arg(hash, format.isEmpty() ? QStringLiteral("jpg") : format);
} else {
const QJsonObject img = artist["image"].toObject();
artUrl = img["large"].toString();
if (artUrl.isEmpty()) artUrl = img["small"].toString();
}
if (!artUrl.isEmpty() && artUrl != m_currentArtUrl) {
m_currentArtUrl = artUrl;
m_nam->get(QNetworkRequest(QUrl(artUrl)));
} else if (artUrl.isEmpty()) {
m_artLabel->setPixmap(QPixmap());
m_currentArtUrl.clear();
}
// Popular tracks (flat array)
const QJsonArray topTracks = artist["top_tracks"].toArray();
m_topTracks->loadTracks(topTracks);
const int ttCount = topTracks.size();
disconnect(m_topTracksToggle, &QPushButton::toggled, nullptr, nullptr);
connect(m_topTracksToggle, &QPushButton::toggled, m_topTracks, &QWidget::setVisible);
connect(m_topTracksToggle, &QPushButton::toggled, this, [this, ttCount](bool open) {
const QString a = open ? QStringLiteral("") : QStringLiteral("");
m_topTracksToggle->setText(ttCount > 0
? QStringLiteral("%1Popular Tracks (%2)").arg(a).arg(ttCount)
: a + tr("Popular Tracks"));
});
m_topTracksToggle->setChecked(true);
m_topTracks->setVisible(true);
m_topTracksToggle->setText(ttCount > 0
? QStringLiteral("▼ Popular Tracks (%1)").arg(ttCount)
: QStringLiteral("▼ Popular Tracks"));
m_topTracksSection->setVisible(!topTracks.isEmpty());
// Reset shuffle button state
m_shuffleBtn->setEnabled(true);
m_shuffleBtn->setText(tr("⇄ Shuffle All"));
// Clear release sections
for (ArtistSection *sec : {m_secAlbums, m_secEps, m_secLive, m_secCompilations, m_secOther}) {
sec->setAlbums({});
sec->setVisible(false);
}
}
void ArtistView::setReleases(const QString &releaseType, const QJsonArray &items,
bool /*hasMore*/, int /*offset*/)
{
ArtistSection *sec = nullptr;
if (releaseType == QStringLiteral("album")) sec = m_secAlbums;
else if (releaseType == QStringLiteral("epSingle")) sec = m_secEps;
else if (releaseType == QStringLiteral("live")) sec = m_secLive;
else if (releaseType == QStringLiteral("compilation")) sec = m_secCompilations;
else sec = m_secOther;
// Rust auto-paginates, so we always get the full list at once
sec->setAlbums(items);
sec->setVisible(!sec->isEmpty());
}
void ArtistView::setFavArtistIds(const QSet<qint64> &ids)
{
m_favArtistIds = ids;
if (m_artistId > 0)
setFaved(ids.contains(m_artistId));
}
void ArtistView::onDeepShuffleTracks(const QJsonArray &tracks)
{
m_shuffleBtn->setEnabled(true);
m_shuffleBtn->setText(tr("⇄ Shuffle All"));
if (tracks.isEmpty()) return;
m_queue->setContext(tracks, 0);
m_queue->shuffleNow();
const QJsonObject first = m_queue->current();
const qint64 id = static_cast<qint64>(first["id"].toDouble());
if (id > 0)
emit playTrackRequested(id);
}
QStringList ArtistView::allAlbumIds() const
{
QStringList ids;
for (const ArtistSection *sec : {m_secAlbums, m_secEps, m_secLive, m_secCompilations, m_secOther})
ids.append(sec->albumIds());
return ids;
}
void ArtistView::setFaved(bool faved)
{
m_isFaved = faved;
if (faved) {
m_favBtn->setText(tr("♥ Favourited"));
m_favBtn->setStyleSheet(kBtnBase +
QStringLiteral("QPushButton { background: #2a2a2a; color: #FFB232; border: 1px solid #FFB232; }"
"QPushButton:pressed { background: #333; }"));
} else {
m_favBtn->setText(tr("♡ Favourite"));
m_favBtn->setStyleSheet(kBtnBase +
QStringLiteral("QPushButton { background: #2a2a2a; color: #ccc; border: 1px solid #555; }"
"QPushButton:pressed { background: #333; }"));
}
}

View File

@@ -1,64 +1,93 @@
#pragma once
#include "albumlistview.hpp"
#include "../list/tracks.hpp"
#include "../backend/qobuzbackend.hpp"
#include "../playqueue.hpp"
#include <QWidget>
#include <QVBoxLayout>
#include <QLabel>
#include <QFont>
#include <QTextEdit>
#include <QPushButton>
#include <QNetworkAccessManager>
#include <QJsonObject>
#include <QJsonArray>
#include <QSet>
/// Artist detail page: name, biography summary, and their album list.
class ArtistView : public QWidget
class AlbumListView;
/// One collapsible section (Albums / EPs / Live / etc.) inside ArtistView.
class ArtistSection : public QWidget
{
Q_OBJECT
public:
explicit ArtistView(QWidget *parent = nullptr) : QWidget(parent)
{
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(8, 8, 8, 8);
layout->setSpacing(6);
explicit ArtistSection(const QString &title, const QString &releaseType, QWidget *parent = nullptr);
m_nameLabel = new QLabel(this);
QFont f = m_nameLabel->font();
f.setPointSize(f.pointSize() + 4);
f.setBold(true);
m_nameLabel->setFont(f);
m_bioLabel = new QLabel(this);
m_bioLabel->setWordWrap(true);
m_bioLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft);
m_bioLabel->setMaximumHeight(80);
m_albums = new AlbumListView(this);
layout->addWidget(m_nameLabel);
layout->addWidget(m_bioLabel);
layout->addWidget(m_albums, 1);
connect(m_albums, &AlbumListView::albumSelected,
this, &ArtistView::albumSelected);
}
void setArtist(const QJsonObject &artist)
{
m_nameLabel->setText(artist["name"].toString());
const QString summary = artist["biography"].toObject()["summary"].toString();
m_bioLabel->setText(summary);
m_bioLabel->setVisible(!summary.isEmpty());
const QJsonArray albums = artist["albums"].toObject()["items"].toArray();
m_albums->setAlbums(albums);
}
void setAlbums(const QJsonArray &albums);
bool isEmpty() const;
QStringList albumIds() const;
void setArtistPageMode();
signals:
void albumSelected(const QString &albumId);
private:
QLabel *m_nameLabel = nullptr;
QLabel *m_bioLabel = nullptr;
AlbumListView *m_albums = nullptr;
QString m_baseTitle;
QString m_releaseType;
QPushButton *m_toggle = nullptr;
AlbumListView *m_list = nullptr;
void updateToggleText();
};
/// Artist detail page.
class ArtistView : public QWidget
{
Q_OBJECT
public:
explicit ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent = nullptr);
void setArtist(const QJsonObject &artist);
void setReleases(const QString &releaseType, const QJsonArray &items,
bool hasMore = false, int offset = 0);
void setFavArtistIds(const QSet<qint64> &ids);
void onDeepShuffleTracks(const QJsonArray &tracks);
signals:
void albumSelected(const QString &albumId);
void playTrackRequested(qint64 trackId);
private:
QobuzBackend *m_backend = nullptr;
PlayQueue *m_queue = nullptr;
qint64 m_artistId = 0;
// Header widgets
QLabel *m_artLabel = nullptr;
QLabel *m_nameLabel = nullptr;
QTextEdit *m_bioEdit = nullptr;
QPushButton *m_playBtn = nullptr;
QPushButton *m_shuffleTopBtn = nullptr;
QPushButton *m_shuffleBtn = nullptr;
QPushButton *m_favBtn = nullptr;
QNetworkAccessManager *m_nam = nullptr;
QString m_currentArtUrl;
bool m_isFaved = false;
QSet<qint64> m_favArtistIds;
// Popular tracks section
QWidget *m_topTracksSection = nullptr;
QPushButton *m_topTracksToggle = nullptr;
List::Tracks *m_topTracks = nullptr;
// Release sections
ArtistSection *m_secAlbums = nullptr;
ArtistSection *m_secEps = nullptr;
ArtistSection *m_secLive = nullptr;
ArtistSection *m_secCompilations = nullptr;
ArtistSection *m_secOther = nullptr;
QStringList allAlbumIds() const;
void setFaved(bool faved);
};

View File

@@ -7,8 +7,6 @@
namespace Context
{
static constexpr int ArtSize = 96;
View::View(QobuzBackend *backend, QWidget *parent)
: QDockWidget(tr("Now Playing"), parent)
, m_backend(backend)
@@ -24,13 +22,8 @@ View::View(QobuzBackend *backend, QWidget *parent)
layout->setContentsMargins(8, 8, 8, 8);
layout->setSpacing(6);
m_albumArt = new QLabel(container);
m_albumArt->setFixedSize(ArtSize, ArtSize);
m_albumArt->setScaledContents(true);
m_albumArt->setAlignment(Qt::AlignCenter);
m_albumArt->setStyleSheet(QStringLiteral(
"background: #1a1a1a; border-radius: 4px;"));
layout->addWidget(m_albumArt, 0, Qt::AlignCenter);
m_albumArt = new ArtWidget(container);
layout->addWidget(m_albumArt);
m_title = new QLabel(tr("Not playing"), container);
m_title->setAlignment(Qt::AlignCenter);
@@ -48,7 +41,6 @@ View::View(QobuzBackend *backend, QWidget *parent)
layout->addStretch();
setWidget(container);
setMinimumWidth(160);
connect(m_backend, &QobuzBackend::trackChanged, this, &View::onTrackChanged);
}
@@ -63,7 +55,6 @@ void View::onTrackChanged(const QJsonObject &track)
m_title->setText(title.isEmpty() ? tr("Not playing") : title);
m_artist->setText(artist);
// Prefer "large" image, fall back to "small"
const QJsonObject img = track["album"].toObject()["image"].toObject();
QString artUrl = img["large"].toString();
if (artUrl.isEmpty())

View File

@@ -3,13 +3,49 @@
#include "../../backend/qobuzbackend.hpp"
#include <QDockWidget>
#include <QWidget>
#include <QLabel>
#include <QPixmap>
#include <QPainter>
#include <QPaintEvent>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QJsonObject>
namespace Context
{
/// Square art widget: always as wide as its parent allows, height follows width.
class ArtWidget : public QWidget
{
public:
explicit ArtWidget(QWidget *parent = nullptr) : QWidget(parent)
{
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
}
void setPixmap(const QPixmap &px) { m_pix = px; update(); }
bool hasHeightForWidth() const override { return true; }
int heightForWidth(int w) const override { return w; }
protected:
void paintEvent(QPaintEvent *) override
{
QPainter p(this);
if (m_pix.isNull()) {
p.fillRect(rect(), QColor(0x1a, 0x1a, 0x1a));
return;
}
const QPixmap scaled = m_pix.scaled(size(), Qt::KeepAspectRatio, Qt::SmoothTransformation);
p.fillRect(rect(), QColor(0x1a, 0x1a, 0x1a));
p.drawPixmap((width() - scaled.width()) / 2,
(height() - scaled.height()) / 2,
scaled);
}
private:
QPixmap m_pix;
};
class View : public QDockWidget
{
Q_OBJECT
@@ -23,7 +59,7 @@ namespace Context
private:
QobuzBackend *m_backend = nullptr;
QLabel *m_albumArt = nullptr;
ArtWidget *m_albumArt = nullptr;
QLabel *m_title = nullptr;
QLabel *m_artist = nullptr;
QNetworkAccessManager *m_nam = nullptr;

View File

@@ -1,6 +1,7 @@
#include "maincontent.hpp"
#include <QVBoxLayout>
#include <QPushButton>
MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
: QWidget(parent)
@@ -15,17 +16,38 @@ MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
m_welcome = new QLabel(
tr("<h2>Welcome to Qobuz</h2>"
"<p>Select something from the library on the left to get started,<br>"
"or use the search panel (🔍) to find music.</p>"),
"or use the search panel to find music.</p>"),
this);
m_welcome->setAlignment(Qt::AlignCenter);
m_tracks = new List::Tracks(m_backend, queue, this);
// Tracks page: context header + track list
auto *tracksPage = new QWidget(this);
auto *tracksLayout = new QVBoxLayout(tracksPage);
tracksLayout->setContentsMargins(0, 0, 0, 0);
tracksLayout->setSpacing(0);
m_header = new TrackContextHeader(tracksPage);
m_header->hide();
m_tracks = new List::Tracks(m_backend, queue, tracksPage);
tracksLayout->addWidget(m_header);
tracksLayout->addWidget(m_tracks, 1);
QObject::connect(m_header->playButton(), &QPushButton::clicked,
[this] { m_tracks->playAll(false); });
QObject::connect(m_header->shuffleButton(), &QPushButton::clicked,
[this] { m_tracks->playAll(true); });
QObject::connect(m_header->subtitleButton(), &QPushButton::clicked,
[this] {
const qint64 id = m_header->artistId();
if (id > 0) emit artistRequested(id);
});
m_albumList = new AlbumListView(this);
m_artistList = new ArtistListView(this);
m_artistView = new ArtistView(this);
m_artistView = new ArtistView(backend, queue, this);
m_stack->addWidget(m_welcome); // 0
m_stack->addWidget(m_tracks); // 1
m_stack->addWidget(tracksPage); // 1
m_stack->addWidget(m_albumList); // 2
m_stack->addWidget(m_artistList); // 3
m_stack->addWidget(m_artistView); // 4
@@ -35,30 +57,35 @@ MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
connect(m_albumList, &AlbumListView::albumSelected, this, &MainContent::albumRequested);
connect(m_artistList, &ArtistListView::artistSelected, this, &MainContent::artistRequested);
connect(m_artistView, &ArtistView::albumSelected, this, &MainContent::albumRequested);
connect(m_artistView, &ArtistView::playTrackRequested, this, &MainContent::playTrackRequested);
}
void MainContent::showWelcome() { m_stack->setCurrentIndex(0); }
void MainContent::showAlbum(const QJsonObject &album)
{
m_header->setAlbum(album);
m_tracks->loadAlbum(album);
m_stack->setCurrentIndex(1);
}
void MainContent::showPlaylist(const QJsonObject &playlist)
{
m_header->setPlaylist(playlist);
m_tracks->loadPlaylist(playlist);
m_stack->setCurrentIndex(1);
}
void MainContent::showFavTracks(const QJsonObject &result)
{
m_header->hide();
m_tracks->loadTracks(result["items"].toArray());
m_stack->setCurrentIndex(1);
}
void MainContent::showSearchTracks(const QJsonArray &tracks)
{
m_header->hide();
m_tracks->loadSearchTracks(tracks);
m_stack->setCurrentIndex(1);
}
@@ -80,3 +107,18 @@ void MainContent::showArtist(const QJsonObject &artist)
m_artistView->setArtist(artist);
m_stack->setCurrentIndex(4);
}
void MainContent::updateArtistReleases(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset)
{
m_artistView->setReleases(releaseType, items, hasMore, offset);
}
void MainContent::setFavArtistIds(const QSet<qint64> &ids)
{
m_artistView->setFavArtistIds(ids);
}
void MainContent::onDeepShuffleTracks(const QJsonArray &tracks)
{
m_artistView->onDeepShuffleTracks(tracks);
}

View File

@@ -6,6 +6,7 @@
#include "albumlistview.hpp"
#include "artistlistview.hpp"
#include "artistview.hpp"
#include "trackcontextheader.hpp"
#include <QWidget>
#include <QLabel>
@@ -30,16 +31,23 @@ public:
void showFavAlbums(const QJsonObject &result);
void showFavArtists(const QJsonObject &result);
void showArtist(const QJsonObject &artist);
void updateArtistReleases(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset);
void setFavArtistIds(const QSet<qint64> &ids);
void onDeepShuffleTracks(const QJsonArray &tracks);
ArtistView *artistView() const { return m_artistView; }
signals:
void albumRequested(const QString &albumId);
void artistRequested(qint64 artistId);
void playTrackRequested(qint64 trackId);
private:
QobuzBackend *m_backend = nullptr;
QStackedWidget *m_stack = nullptr;
QLabel *m_welcome = nullptr;
List::Tracks *m_tracks = nullptr;
TrackContextHeader *m_header = nullptr;
AlbumListView *m_albumList = nullptr;
ArtistListView *m_artistList = nullptr;
ArtistView *m_artistView = nullptr;

View File

@@ -2,9 +2,9 @@
#include "../util/settings.hpp"
#include "../model/tracklistmodel.hpp"
#include <QWidget>
#include <QHBoxLayout>
#include <QNetworkRequest>
#include <QResizeEvent>
#include <QMenu>
MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
: QToolBar(parent)
@@ -19,17 +19,41 @@ MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
m_nam = new QNetworkAccessManager(this);
connect(m_nam, &QNetworkAccessManager::finished, this, &MainToolBar::onAlbumArtReady);
// --- Album art thumbnail ---
// ---- Album art ----
m_artLabel = new QLabel(this);
m_artLabel->setFixedSize(36, 36);
m_artLabel->setScaledContents(true);
m_artLabel->setStyleSheet("border: 1px solid #444; background: #1a1a1a;");
m_artLabel->setPixmap(QIcon(":/res/icons/view-media-album-cover.svg")
.pixmap(32, 32));
m_artLabel->setStyleSheet("border: 1px solid #444; background: #1a1a1a; border-radius: 3px;");
m_artLabel->setPixmap(QIcon(":/res/icons/view-media-album-cover.svg").pixmap(32, 32));
addWidget(m_artLabel);
// ---- Track label ----
m_trackLabel = new QLabel(tr("Not playing"), this);
m_trackLabel->setMinimumWidth(80);
m_trackLabel->setMaximumWidth(200);
m_trackLabel->setAlignment(Qt::AlignVCenter | Qt::AlignLeft);
m_trackLabel->setTextFormat(Qt::RichText);
addWidget(m_trackLabel);
m_trackLabel->setContextMenuPolicy(Qt::CustomContextMenu);
connect(m_trackLabel, &QLabel::customContextMenuRequested,
this, [this](const QPoint &pos) {
if (m_currentTrack.isEmpty()) return;
QMenu menu(this);
const QString albumId = m_currentTrack["album"].toObject()["id"].toString();
const qint64 artistId = static_cast<qint64>(
m_currentTrack["performer"].toObject()["id"].toDouble());
if (!albumId.isEmpty())
menu.addAction(tr("Go to Album"), this, [this, albumId] { emit albumRequested(albumId); });
if (artistId > 0)
menu.addAction(tr("Go to Artist"), this, [this, artistId] { emit artistRequested(artistId); });
if (!menu.isEmpty())
menu.exec(m_trackLabel->mapToGlobal(pos));
});
addSeparator();
// --- Playback controls ---
// ---- Media controls ----
m_previous = addAction(Icon::previous(), tr("Previous"));
connect(m_previous, &QAction::triggered, this, &MainToolBar::onPrevious);
@@ -39,67 +63,72 @@ MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
m_next = addAction(Icon::next(), tr("Next"));
connect(m_next, &QAction::triggered, this, &MainToolBar::onNext);
addSeparator();
// ---- Left spacer (pushes progress toward center) ----
m_leftSpacer = new QWidget(this);
m_leftSpacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
addWidget(m_leftSpacer);
// --- Track info label ---
m_trackLabel = new QLabel(tr("Not playing"), this);
m_trackLabel->setMinimumWidth(180);
m_trackLabel->setMaximumWidth(340);
m_trackLabel->setAlignment(Qt::AlignVCenter | Qt::AlignLeft);
addWidget(m_trackLabel);
addSeparator();
// --- Progress slider ---
// ---- Progress slider ----
m_progress = new ClickableSlider(Qt::Horizontal, this);
m_progress->setRange(0, 1000);
m_progress->setValue(0);
m_progress->setMinimumWidth(160);
m_progress->setMaximumWidth(380);
m_progress->setMinimumWidth(200);
m_progress->setMaximumWidth(500);
addWidget(m_progress);
connect(m_progress, &QSlider::sliderPressed, this, [this] { m_seeking = true; });
connect(m_progress, &QSlider::sliderReleased, this, &MainToolBar::onProgressReleased);
m_timeLabel = new QLabel("0:00 / 0:00", this);
addWidget(m_timeLabel);
// ---- Position label ----
m_position = new QLabel(QStringLiteral("0:00 / 0:00"), this);
m_position->setAlignment(Qt::AlignVCenter | Qt::AlignLeft);
m_position->setMinimumWidth(80);
addWidget(m_position);
addSeparator();
// ---- Right spacer (mirrors left spacer) ----
m_rightSpacer = new QWidget(this);
m_rightSpacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
addWidget(m_rightSpacer);
// --- Volume ---
// ---- Shuffle ----
m_shuffle = addAction(Icon::get(QStringLiteral("media-playlist-shuffle")), tr("Shuffle"));
m_shuffle->setCheckable(true);
connect(m_shuffle, &QAction::toggled, this, &MainToolBar::onShuffleToggled);
// ---- Volume ----
m_volume = new VolumeButton(this);
m_volume->setValue(AppSettings::instance().volume());
addWidget(m_volume);
connect(m_volume, &VolumeButton::volumeChanged, this, &MainToolBar::onVolumeChanged);
addSeparator();
// --- Shuffle ---
m_shuffle = addAction(Icon::get(QStringLiteral("media-playlist-shuffle")), tr("Shuffle"));
m_shuffle->setCheckable(true);
connect(m_shuffle, &QAction::toggled, this, &MainToolBar::onShuffleToggled);
addSeparator();
// --- Queue toggle ---
// ---- Queue toggle ----
m_queueBtn = addAction(Icon::queue(), tr("Queue"));
m_queueBtn->setCheckable(true);
connect(m_queueBtn, &QAction::toggled, this, &MainToolBar::queueToggled);
// --- Search toggle ---
// ---- Search toggle ----
m_search = addAction(Icon::search(), tr("Search"));
m_search->setCheckable(true);
connect(m_search, &QAction::toggled, this, &MainToolBar::searchToggled);
// --- Backend signals ---
// ---- Backend signals ----
connect(m_backend, &QobuzBackend::stateChanged, this, &MainToolBar::onBackendStateChanged);
connect(m_backend, &QobuzBackend::trackChanged, this, &MainToolBar::onTrackChanged);
connect(m_backend, &QobuzBackend::positionChanged, this, &MainToolBar::onPositionChanged);
connect(m_backend, &QobuzBackend::trackFinished, this, &MainToolBar::onTrackFinished);
// --- Queue signals ---
// ---- Queue signals ----
connect(m_queue, &PlayQueue::queueChanged, this, &MainToolBar::onQueueChanged);
onQueueChanged(); // initialise button states
onQueueChanged();
}
// ---- resize: keep spacers equal so progress stays centred ----
void MainToolBar::resizeEvent(QResizeEvent *event)
{
QToolBar::resizeEvent(event);
const int spacerWidth = event->size().width() / 6;
m_leftSpacer->setMinimumWidth(spacerWidth);
m_rightSpacer->setMinimumWidth(spacerWidth);
}
// ---- public ----
@@ -113,6 +142,7 @@ void MainToolBar::setPlaying(bool playing)
void MainToolBar::setCurrentTrack(const QJsonObject &track)
{
m_currentTrack = track;
const QString title = track["title"].toString();
const QString artist = track["performer"].toObject()["name"].toString().isEmpty()
? track["album"].toObject()["artist"].toObject()["name"].toString()
@@ -121,12 +151,13 @@ void MainToolBar::setCurrentTrack(const QJsonObject &track)
if (title.isEmpty()) {
m_trackLabel->setText(tr("Not playing"));
} else if (artist.isEmpty()) {
m_trackLabel->setText(title);
m_trackLabel->setText(title.toHtmlEscaped());
} else {
m_trackLabel->setText(QStringLiteral("%1 — %2").arg(artist, title));
m_trackLabel->setText(QStringLiteral("<span style='font-weight:600;'>%1</span>"
"<br><span style='font-size:small; color:#aaa;'>%2</span>")
.arg(title.toHtmlEscaped(), artist.toHtmlEscaped()));
}
// Album art
const QString artUrl = track["album"].toObject()["image"].toObject()["small"].toString();
if (!artUrl.isEmpty() && artUrl != m_currentArtUrl) {
m_currentArtUrl = artUrl;
@@ -137,12 +168,12 @@ void MainToolBar::setCurrentTrack(const QJsonObject &track)
void MainToolBar::updateProgress(quint64 position, quint64 duration)
{
if (m_seeking) return;
const int sliderPos = duration > 0
? static_cast<int>(position * 1000 / duration) : 0;
const int sliderPos = (duration > 0)
? static_cast<int>(qMin(position * 1000 / duration, quint64(1000))) : 0;
m_progress->blockSignals(true);
m_progress->setValue(sliderPos);
m_progress->blockSignals(false);
m_timeLabel->setText(
m_position->setText(
QStringLiteral("%1 / %2")
.arg(TrackListModel::formatDuration(static_cast<qint64>(position)),
TrackListModel::formatDuration(static_cast<qint64>(duration))));
@@ -177,6 +208,11 @@ void MainToolBar::onNext()
void MainToolBar::onProgressReleased()
{
m_seeking = false;
const quint64 dur = m_backend->duration();
if (dur > 0) {
const quint64 target = dur * static_cast<quint64>(m_progress->value()) / 1000;
m_backend->seek(target);
}
}
void MainToolBar::onVolumeChanged(int volume)
@@ -202,13 +238,12 @@ void MainToolBar::onPositionChanged(quint64 position, quint64 duration)
void MainToolBar::onTrackFinished()
{
// Auto-advance queue
if (m_queue->canGoNext()) {
onNext();
} else {
setPlaying(false);
m_progress->setValue(0);
m_timeLabel->setText("0:00 / 0:00");
m_position->setText(QStringLiteral("0:00 / 0:00"));
}
}

View File

@@ -7,7 +7,6 @@
#include "../util/icon.hpp"
#include <QToolBar>
#include <QToolButton>
#include <QLabel>
#include <QAction>
#include <QNetworkAccessManager>
@@ -28,6 +27,11 @@ public:
signals:
void searchToggled(bool visible);
void queueToggled(bool visible);
void albumRequested(const QString &albumId);
void artistRequested(qint64 artistId);
protected:
void resizeEvent(QResizeEvent *event) override;
private slots:
void onPlayPause();
@@ -52,19 +56,21 @@ private:
QLabel *m_artLabel = nullptr;
QLabel *m_trackLabel = nullptr;
ClickableSlider *m_progress = nullptr;
QLabel *m_timeLabel = nullptr;
VolumeButton *m_volume = nullptr;
QAction *m_previous = nullptr;
QAction *m_playPause = nullptr;
QAction *m_next = nullptr;
QWidget *m_leftSpacer = nullptr;
ClickableSlider *m_progress = nullptr;
QLabel *m_position = nullptr;
QWidget *m_rightSpacer = nullptr;
QAction *m_shuffle = nullptr;
VolumeButton *m_volume = nullptr;
QAction *m_queueBtn = nullptr;
QAction *m_search = nullptr;
QNetworkAccessManager *m_nam = nullptr;
QString m_currentArtUrl;
QJsonObject m_currentTrack;
bool m_playing = false;
bool m_seeking = false;
};

View File

@@ -4,9 +4,119 @@
#include <QHBoxLayout>
#include <QMenu>
#include <QAction>
#include <QPainter>
#include <QStyledItemDelegate>
#include <QApplication>
static constexpr int UpcomingIndexRole = Qt::UserRole + 1;
static constexpr int IsPlayNextRole = Qt::UserRole + 2;
static constexpr int TrackJsonRole = Qt::UserRole + 3;
static constexpr int ArtistRole = Qt::UserRole + 4;
static constexpr int DurationRole = Qt::UserRole + 5;
// ---- Custom delegate -------------------------------------------------------
class QueueDelegate : public QStyledItemDelegate
{
public:
explicit QueueDelegate(QObject *parent = nullptr) : QStyledItemDelegate(parent) {}
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &) const override
{
return QSize(0, QFontMetrics(option.font).height() + 4);
}
void paint(QPainter *p, const QStyleOptionViewItem &option,
const QModelIndex &index) const override
{
p->save();
QStyle *style = option.widget ? option.widget->style() : QApplication::style();
style->drawPrimitive(QStyle::PE_PanelItemViewItem, &option, p, option.widget);
const bool isPlayNext = index.data(IsPlayNextRole).toBool();
const QString title = index.data(Qt::DisplayRole).toString();
const QString artist = index.data(ArtistRole).toString();
const int dur = index.data(DurationRole).toInt();
const QRect r = option.rect.adjusted(10, 0, -10, 0);
// Duration right-aligned
QString durStr;
if (dur > 0) {
const int m = dur / 60, s = dur % 60;
durStr = QStringLiteral("%1:%2").arg(m).arg(s, 2, 10, QLatin1Char('0'));
}
const QPalette &pal = option.palette;
const bool selected = option.state & QStyle::State_Selected;
QColor titleColor = selected ? pal.highlightedText().color() : pal.text().color();
QColor dimColor = titleColor;
dimColor.setAlpha(150);
if (isPlayNext && !selected)
titleColor = titleColor.lighter(130);
QFont titleFont = option.font;
titleFont.setWeight(QFont::Medium);
QFont subFont = option.font;
subFont.setPointSizeF(option.font.pointSizeF() * 0.85);
// Draw duration on the far right
int durW = 0;
if (!durStr.isEmpty()) {
durW = QFontMetrics(subFont).horizontalAdvance(durStr) + 6;
p->setFont(subFont);
p->setPen(dimColor);
p->drawText(QRect(r.right() - durW, r.top(), durW, r.height()),
Qt::AlignRight | Qt::AlignVCenter, durStr);
}
// Available width for title + separator + artist
const int available = r.width() - durW;
const QFontMetrics titleFm(titleFont);
const QFontMetrics subFm(subFont);
const QString sep = artist.isEmpty() ? QString() : QStringLiteral(" · ");
const int sepW = sep.isEmpty() ? 0 : subFm.horizontalAdvance(sep);
// Title gets up to 60% of available, artist gets the rest
const int maxTitle = qMax(0, available * 6 / 10 - sepW);
const int maxArtist = qMax(0, available - sepW
- qMin(titleFm.horizontalAdvance(title), maxTitle));
const QString elidedTitle = titleFm.elidedText(title, Qt::ElideRight, maxTitle);
const int drawnTitleW = titleFm.horizontalAdvance(elidedTitle);
int x = r.left();
// Title
p->setFont(titleFont);
p->setPen(titleColor);
p->drawText(x, r.top(), drawnTitleW, r.height(),
Qt::AlignLeft | Qt::AlignVCenter, elidedTitle);
x += drawnTitleW;
// Separator + artist
if (!artist.isEmpty()) {
p->setFont(subFont);
p->setPen(dimColor);
p->drawText(x, r.top(), sepW, r.height(),
Qt::AlignLeft | Qt::AlignVCenter, sep);
x += sepW;
const QString elidedArtist = subFm.elidedText(artist, Qt::ElideRight, maxArtist);
p->drawText(x, r.top(), maxArtist, r.height(),
Qt::AlignLeft | Qt::AlignVCenter, elidedArtist);
}
p->restore();
}
};
// ---- QueuePanel ------------------------------------------------------------
QueuePanel::QueuePanel(PlayQueue *queue, QWidget *parent)
: QDockWidget(tr("Queue"), parent)
@@ -20,7 +130,6 @@ QueuePanel::QueuePanel(PlayQueue *queue, QWidget *parent)
layout->setContentsMargins(4, 4, 4, 4);
layout->setSpacing(4);
// Header row: count label + Clear button
auto *headerRow = new QHBoxLayout;
m_countLabel = new QLabel(tr("Up next: 0 tracks"), container);
m_clearBtn = new QPushButton(tr("Clear"), container);
@@ -32,25 +141,28 @@ QueuePanel::QueuePanel(PlayQueue *queue, QWidget *parent)
m_list = new QListWidget(container);
m_list->setAlternatingRowColors(true);
m_list->setContextMenuPolicy(Qt::CustomContextMenu);
m_list->setDragDropMode(QAbstractItemView::InternalMove);
m_list->setDefaultDropAction(Qt::MoveAction);
m_list->setItemDelegate(new QueueDelegate(m_list));
layout->addWidget(m_list, 1);
setWidget(container);
setMinimumWidth(200);
connect(m_queue, &PlayQueue::queueChanged, this, &QueuePanel::refresh);
connect(m_clearBtn, &QPushButton::clicked, this, [this] {
m_queue->clearUpcoming();
});
connect(m_list, &QListWidget::itemDoubleClicked,
this, &QueuePanel::onItemDoubleClicked);
connect(m_list, &QListWidget::customContextMenuRequested,
this, &QueuePanel::onContextMenu);
connect(m_clearBtn, &QPushButton::clicked, this, [this] { m_queue->clearUpcoming(); });
connect(m_list, &QListWidget::itemDoubleClicked, this, &QueuePanel::onItemDoubleClicked);
connect(m_list, &QListWidget::customContextMenuRequested, this, &QueuePanel::onContextMenu);
connect(m_list->model(), &QAbstractItemModel::rowsMoved, this, &QueuePanel::onRowsMoved);
refresh();
}
void QueuePanel::refresh()
{
if (m_refreshing) return;
m_refreshing = true;
m_list->clear();
const QVector<QJsonObject> upcoming = m_queue->upcomingTracks();
@@ -61,32 +173,46 @@ void QueuePanel::refresh()
for (int i = 0; i < upcoming.size(); ++i) {
const QJsonObject &t = upcoming.at(i);
const QString title = t["title"].toString();
const QString base = t["title"].toString();
const QString ver = t["version"].toString().trimmed();
const QString title = ver.isEmpty() ? base : base + QStringLiteral(" (") + ver + QLatin1Char(')');
const QString artist = t["performer"].toObject()["name"].toString().isEmpty()
? t["album"].toObject()["artist"].toObject()["name"].toString()
: t["performer"].toObject()["name"].toString();
const int duration = t["duration"].toInt();
const QString text = artist.isEmpty()
? title
: QStringLiteral("%1 — %2").arg(artist, title);
auto *item = new QListWidgetItem(text, m_list);
auto *item = new QListWidgetItem(title, m_list);
item->setData(UpcomingIndexRole, i);
item->setData(IsPlayNextRole, i < playNextCount);
item->setData(TrackJsonRole, QVariant::fromValue(t));
item->setData(ArtistRole, artist);
item->setData(DurationRole, duration);
}
// "Play Next" tracks shown slightly differently
if (i < playNextCount) {
QFont f = item->font();
f.setItalic(true);
item->setFont(f);
}
}
m_refreshing = false;
}
void QueuePanel::onItemDoubleClicked(QListWidgetItem *item)
{
// Double-clicking an upcoming item is not needed for now (could skip to it later)
Q_UNUSED(item)
const int idx = item->data(UpcomingIndexRole).toInt();
const QJsonObject track = m_queue->skipToUpcoming(idx);
if (track.isEmpty()) return;
const qint64 id = static_cast<qint64>(track["id"].toDouble());
emit skipToTrackRequested(id);
}
void QueuePanel::onRowsMoved()
{
if (m_refreshing) return;
QVector<QJsonObject> newOrder;
newOrder.reserve(m_list->count());
for (int i = 0; i < m_list->count(); ++i)
newOrder.append(m_list->item(i)->data(TrackJsonRole).value<QJsonObject>());
m_refreshing = true;
m_queue->setUpcomingOrder(newOrder);
m_refreshing = false;
}
void QueuePanel::onContextMenu(const QPoint &pos)
@@ -100,12 +226,8 @@ void QueuePanel::onContextMenu(const QPoint &pos)
auto *removeAct = menu.addAction(tr("Remove from queue"));
auto *toTopAct = menu.addAction(tr("Move to top (play next)"));
connect(removeAct, &QAction::triggered, this, [this, idx] {
m_queue->removeUpcoming(idx);
});
connect(toTopAct, &QAction::triggered, this, [this, idx] {
m_queue->moveUpcomingToTop(idx);
});
connect(removeAct, &QAction::triggered, this, [this, idx] { m_queue->removeUpcoming(idx); });
connect(toTopAct, &QAction::triggered, this, [this, idx] { m_queue->moveUpcomingToTop(idx); });
menu.exec(m_list->viewport()->mapToGlobal(pos));
}

View File

@@ -14,14 +14,19 @@ class QueuePanel : public QDockWidget
public:
explicit QueuePanel(PlayQueue *queue, QWidget *parent = nullptr);
signals:
void skipToTrackRequested(qint64 trackId);
private slots:
void refresh();
void onItemDoubleClicked(QListWidgetItem *item);
void onContextMenu(const QPoint &pos);
void onRowsMoved();
private:
PlayQueue *m_queue = nullptr;
QLabel *m_countLabel = nullptr;
QListWidget *m_list = nullptr;
QPushButton *m_clearBtn = nullptr;
bool m_refreshing = false;
};

View File

@@ -1,21 +1,27 @@
#include "view.hpp"
#include "../../util/trackinfo.hpp"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QPushButton>
#include <QHeaderView>
#include <QFont>
#include <QJsonArray>
#include <QMenu>
static constexpr int IdRole = Qt::UserRole + 1;
static constexpr int TypeRole = Qt::UserRole + 2;
static constexpr int JsonRole = Qt::UserRole + 3;
namespace SidePanel
{
// ---- SearchTab ----
SearchTab::SearchTab(QobuzBackend *backend, QWidget *parent)
SearchTab::SearchTab(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
: QWidget(parent)
, m_backend(backend)
, m_queue(queue)
{
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(4, 4, 4, 4);
@@ -23,7 +29,7 @@ SearchTab::SearchTab(QobuzBackend *backend, QWidget *parent)
// Search bar
auto *barLayout = new QHBoxLayout;
m_searchBox = new QLineEdit(this);
m_searchBox->setPlaceholderText(tr("Search Qobuz"));
m_searchBox->setPlaceholderText(tr("Search Qobuz..."));
m_searchBox->setClearButtonEnabled(true);
auto *searchBtn = new QPushButton(tr("Go"), this);
barLayout->addWidget(m_searchBox);
@@ -36,10 +42,16 @@ SearchTab::SearchTab(QobuzBackend *backend, QWidget *parent)
m_trackResults = new QTreeWidget(this);
m_trackResults->setHeaderLabels({tr("Title"), tr("Artist"), tr("Album")});
m_trackResults->setRootIsDecorated(false);
m_trackResults->setContextMenuPolicy(Qt::CustomContextMenu);
m_albumResults = new QTreeWidget(this);
m_albumResults->setHeaderLabels({tr("Album"), tr("Artist")});
m_albumResults->setHeaderLabels({tr(""), tr("Album"), tr("Artist")});
m_albumResults->setRootIsDecorated(false);
m_albumResults->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
m_albumResults->header()->setSectionResizeMode(1, QHeaderView::Stretch);
m_albumResults->header()->setSectionResizeMode(2, QHeaderView::Stretch);
m_albumResults->header()->setStretchLastSection(false);
m_albumResults->setContextMenuPolicy(Qt::CustomContextMenu);
m_artistResults = new QTreeWidget(this);
m_artistResults->setHeaderLabels({tr("Artist")});
@@ -58,6 +70,17 @@ SearchTab::SearchTab(QobuzBackend *backend, QWidget *parent)
connect(m_trackResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked);
connect(m_albumResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked);
connect(m_artistResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked);
// Context menus
connect(m_trackResults, &QTreeWidget::customContextMenuRequested,
this, &SearchTab::onTrackContextMenu);
connect(m_albumResults, &QTreeWidget::customContextMenuRequested,
this, &SearchTab::onAlbumContextMenu);
}
void SearchTab::setUserPlaylists(const QVector<QPair<qint64, QString>> &playlists)
{
m_userPlaylists = playlists;
}
void SearchTab::onSearchSubmit()
@@ -80,18 +103,34 @@ void SearchTab::onSearchResult(const QJsonObject &result)
QStringList{t["title"].toString(), performer, album});
item->setData(0, IdRole, static_cast<qint64>(t["id"].toDouble()));
item->setData(0, TypeRole, QStringLiteral("track"));
item->setData(0, JsonRole, t);
}
// Populate albums
m_albumResults->clear();
{
QFont hiResFont;
hiResFont.setBold(true);
hiResFont.setPointSizeF(hiResFont.pointSizeF() * 0.85);
const QJsonArray albums = result["albums"].toObject()["items"].toArray();
for (const auto &v : albums) {
const QJsonObject a = v.toObject();
const QString artist = a["artist"].toObject()["name"].toString();
const bool hiRes = a["hires_streamable"].toBool();
auto *item = new QTreeWidgetItem(m_albumResults,
QStringList{a["title"].toString(), artist});
item->setData(0, IdRole, a["id"].toString());
QStringList{QString(), a["title"].toString(), artist});
if (hiRes) {
item->setText(0, QStringLiteral("H"));
item->setForeground(0, QColor(QStringLiteral("#FFB232")));
item->setFont(0, hiResFont);
item->setTextAlignment(0, Qt::AlignCenter);
}
item->setData(0, TypeRole, QStringLiteral("album"));
item->setData(1, IdRole, a["id"].toString());
item->setData(0, JsonRole, a);
}
}
// Populate artists
@@ -114,26 +153,141 @@ void SearchTab::onItemDoubleClicked(QTreeWidgetItem *item, int)
if (type == QStringLiteral("track")) {
emit trackPlayRequested(item->data(0, IdRole).toLongLong());
} else if (type == QStringLiteral("album")) {
emit albumSelected(item->data(0, IdRole).toString());
emit albumSelected(item->data(1, IdRole).toString());
} else if (type == QStringLiteral("artist")) {
emit artistSelected(item->data(0, IdRole).toLongLong());
}
}
void SearchTab::onTrackContextMenu(const QPoint &pos)
{
auto *item = m_trackResults->itemAt(pos);
if (!item) return;
const qint64 trackId = item->data(0, IdRole).toLongLong();
const QJsonObject trackJson = item->data(0, JsonRole).toJsonObject();
if (trackId <= 0) return;
QMenu menu(this);
auto *playNow = menu.addAction(tr("Play now"));
auto *playNext = menu.addAction(tr("Play next"));
auto *addQueue = menu.addAction(tr("Add to queue"));
menu.addSeparator();
auto *addFav = menu.addAction(tr("Add to favorites"));
// Open album / artist
const QString albumId = trackJson["album"].toObject()["id"].toString();
const qint64 artistId = static_cast<qint64>(
trackJson["performer"].toObject()["id"].toDouble());
const QString artistName = trackJson["performer"].toObject()["name"].toString();
const QString albumTitle = trackJson["album"].toObject()["title"].toString();
menu.addSeparator();
if (!albumId.isEmpty()) {
auto *openAlbum = menu.addAction(tr("Go to album: %1").arg(albumTitle));
connect(openAlbum, &QAction::triggered, this, [this, albumId] {
emit albumSelected(albumId);
});
}
if (artistId > 0) {
auto *openArtist = menu.addAction(tr("Go to artist: %1").arg(artistName));
connect(openArtist, &QAction::triggered, this, [this, artistId] {
emit artistSelected(artistId);
});
}
// Add to playlist submenu
if (!m_userPlaylists.isEmpty()) {
menu.addSeparator();
auto *plMenu = menu.addMenu(tr("Add to playlist"));
for (const auto &pl : m_userPlaylists) {
auto *act = plMenu->addAction(pl.second);
connect(act, &QAction::triggered, this, [this, trackId, plId = pl.first] {
emit addToPlaylistRequested(trackId, plId);
});
}
}
// Track info
menu.addSeparator();
auto *info = menu.addAction(tr("Track info..."));
connect(playNow, &QAction::triggered, this, [this, trackId] {
emit trackPlayRequested(trackId);
});
connect(playNext, &QAction::triggered, this, [this, trackJson] {
m_queue->playNext(trackJson);
});
connect(addQueue, &QAction::triggered, this, [this, trackJson] {
m_queue->addToQueue(trackJson);
});
connect(addFav, &QAction::triggered, this, [this, trackId] {
m_backend->addFavTrack(trackId);
});
connect(info, &QAction::triggered, this, [this, trackJson] {
showTrackInfo(trackJson);
});
menu.exec(m_trackResults->viewport()->mapToGlobal(pos));
}
void SearchTab::onAlbumContextMenu(const QPoint &pos)
{
auto *item = m_albumResults->itemAt(pos);
if (!item) return;
const QString albumId = item->data(1, IdRole).toString();
const QJsonObject albumJson = item->data(0, JsonRole).toJsonObject();
if (albumId.isEmpty()) return;
QMenu menu(this);
auto *openAlbum = menu.addAction(tr("Open album"));
auto *addFav = menu.addAction(tr("Add to favorites"));
const qint64 artistId = static_cast<qint64>(
albumJson["artist"].toObject()["id"].toDouble());
const QString artistName = albumJson["artist"].toObject()["name"].toString();
if (artistId > 0) {
menu.addSeparator();
auto *openArtist = menu.addAction(tr("Go to artist: %1").arg(artistName));
connect(openArtist, &QAction::triggered, this, [this, artistId] {
emit artistSelected(artistId);
});
}
connect(openAlbum, &QAction::triggered, this, [this, albumId] {
emit albumSelected(albumId);
});
connect(addFav, &QAction::triggered, this, [this, albumId] {
m_backend->addFavAlbum(albumId);
});
menu.exec(m_albumResults->viewport()->mapToGlobal(pos));
}
void SearchTab::showTrackInfo(const QJsonObject &track)
{
TrackInfoDialog::show(track, this);
}
// ---- View ----
View::View(QobuzBackend *backend, QWidget *parent)
View::View(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
: QDockWidget(tr("Search"), parent)
{
setObjectName(QStringLiteral("searchPanel"));
setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetClosable);
m_search = new SearchTab(backend, this);
m_search = new SearchTab(backend, queue, this);
setWidget(m_search);
connect(m_search, &SearchTab::albumSelected, this, &View::albumSelected);
connect(m_search, &SearchTab::artistSelected, this, &View::artistSelected);
connect(m_search, &SearchTab::trackPlayRequested, this, &View::trackPlayRequested);
connect(m_search, &SearchTab::addToPlaylistRequested, this, &View::addToPlaylistRequested);
}
} // namespace SidePanel

View File

@@ -1,6 +1,7 @@
#pragma once
#include "../../backend/qobuzbackend.hpp"
#include "../../playqueue.hpp"
#include <QWidget>
#include <QDockWidget>
@@ -8,6 +9,8 @@
#include <QLineEdit>
#include <QTreeWidget>
#include <QJsonObject>
#include <QVector>
#include <QPair>
namespace SidePanel
{
@@ -15,12 +18,15 @@ namespace SidePanel
{
Q_OBJECT
public:
explicit SearchTab(QobuzBackend *backend, QWidget *parent = nullptr);
explicit SearchTab(QobuzBackend *backend, PlayQueue *queue, QWidget *parent = nullptr);
void setUserPlaylists(const QVector<QPair<qint64, QString>> &playlists);
signals:
void albumSelected(const QString &albumId);
void artistSelected(qint64 artistId);
void trackPlayRequested(qint64 trackId);
void addToPlaylistRequested(qint64 trackId, qint64 playlistId);
private slots:
void onSearchResult(const QJsonObject &result);
@@ -29,18 +35,24 @@ namespace SidePanel
private:
QobuzBackend *m_backend = nullptr;
PlayQueue *m_queue = nullptr;
QLineEdit *m_searchBox = nullptr;
QTabWidget *m_resultTabs = nullptr;
QTreeWidget *m_trackResults = nullptr;
QTreeWidget *m_albumResults = nullptr;
QTreeWidget *m_artistResults = nullptr;
QVector<QPair<qint64, QString>> m_userPlaylists;
void onTrackContextMenu(const QPoint &pos);
void onAlbumContextMenu(const QPoint &pos);
void showTrackInfo(const QJsonObject &track);
};
class View : public QDockWidget
{
Q_OBJECT
public:
explicit View(QobuzBackend *backend, QWidget *parent = nullptr);
explicit View(QobuzBackend *backend, PlayQueue *queue, QWidget *parent = nullptr);
SearchTab *searchTab() const { return m_search; }
@@ -48,6 +60,7 @@ namespace SidePanel
void albumSelected(const QString &albumId);
void artistSelected(qint64 artistId);
void trackPlayRequested(qint64 trackId);
void addToPlaylistRequested(qint64 trackId, qint64 playlistId);
private:
SearchTab *m_search = nullptr;

View File

@@ -0,0 +1,227 @@
#pragma once
#include <QWidget>
#include <QHBoxLayout>
#include <QVBoxLayout>
#include <QLabel>
#include <QFont>
#include <QPixmap>
#include <QPushButton>
#include <QStringList>
#include <QJsonObject>
#include <QJsonArray>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUrl>
/// Header strip shown above the track list when an album or playlist is open.
/// Displays album art, title, subtitle, metadata, and Play/Shuffle buttons.
class TrackContextHeader : public QWidget
{
public:
explicit TrackContextHeader(QWidget *parent = nullptr)
: QWidget(parent)
{
setFixedHeight(148);
auto *hlay = new QHBoxLayout(this);
hlay->setContentsMargins(12, 8, 12, 8);
hlay->setSpacing(14);
m_art = new QLabel(this);
m_art->setFixedSize(120, 120);
m_art->setScaledContents(true);
m_art->setAlignment(Qt::AlignCenter);
m_art->setStyleSheet(QStringLiteral("background: #1a1a1a; border-radius: 4px;"));
hlay->addWidget(m_art, 0, Qt::AlignVCenter);
auto *info = new QWidget(this);
auto *vlay = new QVBoxLayout(info);
vlay->setContentsMargins(0, 0, 0, 0);
vlay->setSpacing(4);
m_title = new QLabel(info);
QFont tf = m_title->font();
tf.setPointSize(tf.pointSize() + 5);
tf.setBold(true);
m_title->setFont(tf);
m_title->setWordWrap(true);
vlay->addWidget(m_title);
m_subtitle = new QPushButton(info);
m_subtitle->setFlat(true);
m_subtitle->setStyleSheet(QStringLiteral(
"QPushButton { border: none; background: none; text-align: left; padding: 0; margin: 0; }"
"QPushButton:enabled:hover { color: #FFB232; }"
"QPushButton:!enabled { color: palette(text); }"
));
QFont sf = m_subtitle->font();
sf.setPointSize(sf.pointSize() + 1);
m_subtitle->setFont(sf);
vlay->addWidget(m_subtitle);
m_meta = new QLabel(info);
QPalette mp = m_meta->palette();
mp.setColor(QPalette::WindowText, QColor(0xaa, 0xaa, 0xaa));
m_meta->setPalette(mp);
vlay->addWidget(m_meta);
// Play / Shuffle buttons
auto *btnRow = new QHBoxLayout;
btnRow->setSpacing(8);
btnRow->setContentsMargins(0, 4, 0, 0);
static const QString btnBase = QStringLiteral(
"QPushButton {"
" padding: 5px 16px;"
" border-radius: 4px;"
" font-weight: bold;"
"}"
"QPushButton:hover { opacity: 0.85; }"
);
m_playBtn = new QPushButton(tr("▶ Play"), info);
m_playBtn->setStyleSheet(btnBase +
QStringLiteral("QPushButton { background: #FFB232; color: #000; }"
"QPushButton:pressed { background: #e09e28; }"));
btnRow->addWidget(m_playBtn);
m_shuffleBtn = new QPushButton(tr("⇄ Shuffle"), info);
m_shuffleBtn->setStyleSheet(btnBase +
QStringLiteral("QPushButton { background: #2a2a2a; color: #FFB232; border: 1px solid #FFB232; }"
"QPushButton:pressed { background: #333; }"));
btnRow->addWidget(m_shuffleBtn);
btnRow->addStretch();
vlay->addLayout(btnRow);
vlay->addStretch(1);
hlay->addWidget(info, 1);
m_nam = new QNetworkAccessManager(this);
QObject::connect(m_nam, &QNetworkAccessManager::finished,
[this](QNetworkReply *reply) {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError)
return;
QPixmap pix;
if (pix.loadFromData(reply->readAll()))
m_art->setPixmap(pix);
});
}
QPushButton *playButton() { return m_playBtn; }
QPushButton *shuffleButton() { return m_shuffleBtn; }
QPushButton *subtitleButton() { return m_subtitle; }
qint64 artistId() const { return m_artistId; }
void setAlbum(const QJsonObject &album)
{
const QString base = album["title"].toString();
const QString ver = album["version"].toString().trimmed();
m_title->setText(ver.isEmpty() ? base : base + QStringLiteral(" (") + ver + QLatin1Char(')'));
m_artistId = static_cast<qint64>(album["artist"].toObject()["id"].toDouble());
m_subtitle->setText(album["artist"].toObject()["name"].toString());
m_subtitle->setEnabled(m_artistId > 0);
m_subtitle->setCursor(m_artistId > 0 ? Qt::PointingHandCursor : Qt::ArrowCursor);
m_meta->setText(buildAlbumMeta(album));
fetchArt(album["image"].toObject());
show();
}
void setPlaylist(const QJsonObject &playlist)
{
m_title->setText(playlist["name"].toString());
m_artistId = 0;
const QString desc = playlist["description"].toString();
const QString owner = playlist["owner"].toObject()["name"].toString();
m_subtitle->setText(desc.isEmpty() ? owner : desc);
m_subtitle->setEnabled(false);
m_subtitle->setCursor(Qt::ArrowCursor);
m_meta->setText(buildPlaylistMeta(playlist));
// Try images300 → images150 → images (API returns mosaic arrays, not image_rectangle)
const QJsonArray imgs300 = playlist["images300"].toArray();
const QJsonArray imgs150 = playlist["images150"].toArray();
const QJsonArray imgs = playlist["images"].toArray();
const QJsonArray &best = !imgs300.isEmpty() ? imgs300
: !imgs150.isEmpty() ? imgs150 : imgs;
if (!best.isEmpty())
fetchUrl(best.first().toString());
else
m_art->setPixmap(QPixmap());
show();
}
private:
void fetchArt(const QJsonObject &img)
{
QString url = img["large"].toString();
if (url.isEmpty()) url = img["small"].toString();
fetchUrl(url);
}
void fetchUrl(const QString &url)
{
if (url.isEmpty()) {
m_art->setPixmap(QPixmap());
return;
}
if (url == m_currentArtUrl)
return;
m_currentArtUrl = url;
m_nam->get(QNetworkRequest(QUrl(url)));
}
static QString formatDuration(int totalSecs)
{
const int h = totalSecs / 3600;
const int m = (totalSecs % 3600) / 60;
if (h > 0)
return QStringLiteral("%1h %2m").arg(h).arg(m);
return QStringLiteral("%1 min").arg(m);
}
static QString buildAlbumMeta(const QJsonObject &album)
{
QStringList parts;
const QString year = album["release_date_original"].toString().left(4);
if (!year.isEmpty()) parts << year;
const int tracks = album["tracks_count"].toInt();
if (tracks > 0) parts << QStringLiteral("%1 tracks").arg(tracks);
const int dur = static_cast<int>(album["duration"].toDouble());
if (dur > 0) parts << formatDuration(dur);
const int bits = album["maximum_bit_depth"].toInt();
const double rate = album["maximum_sampling_rate"].toDouble();
if (bits > 0 && rate > 0) {
const QString rateStr = (rate == static_cast<int>(rate))
? QString::number(static_cast<int>(rate))
: QString::number(rate, 'g', 4);
parts << QStringLiteral("%1-bit / %2 kHz").arg(bits).arg(rateStr);
}
return parts.join(QStringLiteral(" · "));
}
static QString buildPlaylistMeta(const QJsonObject &playlist)
{
QStringList parts;
const int tracks = playlist["tracks_count"].toInt();
if (tracks > 0) parts << QStringLiteral("%1 tracks").arg(tracks);
const int dur = static_cast<int>(playlist["duration"].toDouble());
if (dur > 0) parts << formatDuration(dur);
return parts.join(QStringLiteral(" · "));
}
QLabel *m_art = nullptr;
QLabel *m_title = nullptr;
QPushButton *m_subtitle = nullptr;
QLabel *m_meta = nullptr;
QPushButton *m_playBtn = nullptr;
QPushButton *m_shuffleBtn = nullptr;
QNetworkAccessManager *m_nam = nullptr;
QString m_currentArtUrl;
qint64 m_artistId = 0;
};

View File

@@ -4,10 +4,9 @@
#include "../util/icon.hpp"
#include <QToolButton>
#include <QWidgetAction>
#include <QMenu>
#include <QLabel>
#include <QFrame>
#include <QVBoxLayout>
#include <QLabel>
/// A toolbar button that shows a volume slider popup when clicked.
class VolumeButton : public QToolButton
@@ -17,33 +16,36 @@ class VolumeButton : public QToolButton
public:
explicit VolumeButton(QWidget *parent = nullptr) : QToolButton(parent)
{
setPopupMode(QToolButton::InstantPopup);
setIcon(Icon::volumeHigh());
auto *menu = new QMenu(this);
auto *widget = new QWidget(menu);
widget->setMinimumWidth(72);
auto *layout = new QVBoxLayout(widget);
layout->setContentsMargins(6, 6, 6, 6);
// Qt::Popup closes automatically when the user clicks outside.
m_popup = new QFrame(this, Qt::Popup);
m_popup->setFrameShape(QFrame::StyledPanel);
m_popup->setFrameShadow(QFrame::Raised);
m_label = new QLabel("80%", widget);
auto *layout = new QVBoxLayout(m_popup);
layout->setContentsMargins(10, 10, 10, 10);
layout->setSpacing(6);
m_label = new QLabel(QStringLiteral("80%"), m_popup);
m_label->setAlignment(Qt::AlignCenter);
layout->addWidget(m_label);
m_slider = new ClickableSlider(Qt::Vertical, widget);
m_slider = new ClickableSlider(Qt::Vertical, m_popup);
m_slider->setRange(0, 100);
m_slider->setValue(80);
m_slider->setFixedHeight(120);
layout->addWidget(m_slider, 0, Qt::AlignHCenter);
layout->addWidget(m_label);
layout->addWidget(m_slider);
auto *action = new QWidgetAction(menu);
action->setDefaultWidget(widget);
menu->addAction(action);
setMenu(menu);
// Size the popup at its maximum (label = "100%") and lock it
m_label->setText(QStringLiteral("100%"));
m_popup->adjustSize();
m_popup->setFixedSize(m_popup->sizeHint());
m_label->setText(QStringLiteral("80%"));
connect(this, &QToolButton::clicked, this, &VolumeButton::togglePopup);
connect(m_slider, &QSlider::valueChanged, this, [this](int v) {
m_label->setText(QString::number(v) + "%");
m_label->setText(QString::number(v) + QStringLiteral("%"));
updateIcon(v);
emit volumeChanged(v);
});
@@ -56,14 +58,31 @@ public:
m_slider->blockSignals(true);
m_slider->setValue(v);
m_slider->blockSignals(false);
m_label->setText(QString::number(v) + "%");
m_label->setText(QString::number(v) + QStringLiteral("%"));
updateIcon(v);
}
signals:
void volumeChanged(int volume);
private slots:
void togglePopup()
{
if (m_popup->isVisible()) {
m_popup->hide();
return;
}
// Centre popup horizontally over button, place below it
const QPoint global = mapToGlobal(
QPoint(width() / 2 - m_popup->width() / 2,
height() + 4));
m_popup->move(global);
m_popup->show();
m_popup->raise();
}
private:
QFrame *m_popup = nullptr;
ClickableSlider *m_slider = nullptr;
QLabel *m_label = nullptr;