23 Commits

Author SHA1 Message Date
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
38 changed files with 1966 additions and 374 deletions

View File

@@ -88,9 +88,18 @@ if (UNIX AND NOT APPLE)
target_link_libraries(qobuz-qt PRIVATE asound) target_link_libraries(qobuz-qt PRIVATE asound)
endif () endif ()
# Compiler warnings # Compiler warnings + hardening
if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID STREQUAL "Clang") 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 () endif ()
# D-Bus # D-Bus

View File

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

View File

@@ -30,7 +30,11 @@ enum QobuzEvent {
EV_POSITION = 16, EV_POSITION = 16,
EV_TRACK_URL_OK = 17, EV_TRACK_URL_OK = 17,
EV_TRACK_URL_ERR = 18, EV_TRACK_URL_ERR = 18,
EV_GENERIC_ERR = 19, EV_GENERIC_ERR = 19,
EV_PLAYLIST_CREATED = 20,
EV_PLAYLIST_DELETED = 21,
EV_PLAYLIST_TRACK_ADDED = 22,
EV_USER_OK = 23,
}; };
// Callback signature // Callback signature
@@ -43,6 +47,7 @@ void qobuz_backend_free(QobuzBackendOpaque *backend);
// Auth // Auth
void qobuz_backend_login(QobuzBackendOpaque *backend, const char *email, const char *password); 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_set_token(QobuzBackendOpaque *backend, const char *token);
void qobuz_backend_get_user(QobuzBackendOpaque *backend);
// Catalog // Catalog
void qobuz_backend_search(QobuzBackendOpaque *backend, const char *query, uint32_t offset, uint32_t limit); void qobuz_backend_search(QobuzBackendOpaque *backend, const char *query, uint32_t offset, uint32_t limit);
@@ -69,6 +74,17 @@ uint8_t qobuz_backend_get_volume(const QobuzBackendOpaque *backend);
int qobuz_backend_get_state(const QobuzBackendOpaque *backend); int qobuz_backend_get_state(const QobuzBackendOpaque *backend);
int qobuz_backend_take_track_finished(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);
// 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 // Favorites modification
void qobuz_backend_add_fav_track(QobuzBackendOpaque *backend, int64_t track_id); 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_remove_fav_track(QobuzBackendOpaque *backend, int64_t track_id);

View File

@@ -60,6 +60,8 @@ impl QobuzClient {
.as_secs() .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 { fn request_sig(&self, method: &str, params: &mut Vec<(&str, String)>, ts: u64) -> String {
params.sort_by_key(|(k, _)| *k); params.sort_by_key(|(k, _)| *k);
let mut s = method.replace('/', ""); let mut s = method.replace('/', "");
@@ -89,6 +91,15 @@ impl QobuzClient {
Ok(body) 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 { fn get_request(&self, method: &str) -> reqwest::RequestBuilder {
let mut builder = self.http.get(self.url(method)); let mut builder = self.http.get(self.url(method));
builder = builder.query(&[("app_id", self.app_id.as_str())]); 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> { async fn oauth2_login(&mut self, email: &str, password: &str) -> Result<OAuthLoginResponse> {
let ts = Self::ts(); let ts = Self::ts();
let mut sign_params: Vec<(&str, String)> = vec![ let mut sign_params: Vec<(&str, String)> = vec![
@@ -249,6 +261,7 @@ impl QobuzClient {
.query(&[ .query(&[
("artist_id", artist_id.to_string()), ("artist_id", artist_id.to_string()),
("extra", "albums".to_string()), ("extra", "albums".to_string()),
("albums_limit", "200".to_string()),
]) ])
.send() .send()
.await?; .await?;
@@ -256,6 +269,15 @@ impl QobuzClient {
Ok(serde_json::from_value(body)?) Ok(serde_json::from_value(body)?)
} }
pub async fn get_artist_page(&self, artist_id: i64) -> Result<Value> {
let resp = self
.get_request("artist/page")
.query(&[("artist_id", artist_id.to_string())])
.send()
.await?;
Self::check_response(resp).await
}
// --- Search --- // --- Search ---
pub async fn search(&self, query: &str, offset: u32, limit: u32) -> Result<SearchCatalogDto> { pub async fn search(&self, query: &str, offset: u32, limit: u32) -> Result<SearchCatalogDto> {
@@ -329,6 +351,55 @@ impl QobuzClient {
Ok(serde_json::from_value(body["artists"].clone())?) 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<()> { pub async fn add_fav_track(&self, track_id: i64) -> Result<()> {
let resp = self let resp = self
.get_request("favorite/create") .get_request("favorite/create")

View File

@@ -46,8 +46,10 @@ pub struct SubscriptionDto {
pub struct TrackDto { pub struct TrackDto {
pub id: i64, pub id: i64,
pub title: Option<String>, pub title: Option<String>,
pub version: Option<String>,
pub duration: Option<i64>, pub duration: Option<i64>,
pub track_number: Option<i32>, pub track_number: Option<i32>,
pub playlist_track_id: Option<i64>,
pub album: Option<AlbumDto>, pub album: Option<AlbumDto>,
pub performer: Option<ArtistDto>, pub performer: Option<ArtistDto>,
pub composer: Option<ArtistDto>, pub composer: Option<ArtistDto>,
@@ -97,6 +99,7 @@ pub struct AlbumDto {
pub maximum_sampling_rate: Option<f64>, pub maximum_sampling_rate: Option<f64>,
pub hires_streamable: Option<bool>, pub hires_streamable: Option<bool>,
pub streamable: Option<bool>, pub streamable: Option<bool>,
pub release_type: Option<String>,
pub tracks: Option<TracksWrapper>, pub tracks: Option<TracksWrapper>,
} }
@@ -118,6 +121,10 @@ pub struct ArtistDto {
pub image: Option<ImageDto>, pub image: Option<ImageDto>,
pub biography: Option<BiographyDto>, pub biography: Option<BiographyDto>,
pub albums: Option<SearchResultItems<AlbumDto>>, 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)] #[derive(Debug, Deserialize, Clone, Serialize)]
@@ -182,7 +189,12 @@ pub struct PlaylistDto {
pub duration: Option<i64>, pub duration: Option<i64>,
pub description: Option<String>, pub description: Option<String>,
pub owner: Option<PlaylistOwnerDto>, 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>, pub tracks: Option<TracksWrapper>,
} }

View File

@@ -75,12 +75,20 @@ pub type EventCallback = unsafe extern "C" fn(*mut c_void, c_int, *const c_char)
// ---------- Backend ---------- // ---------- Backend ----------
struct PrefetchedTrack {
track_id: i64,
track: api::models::TrackDto,
url: String,
}
struct BackendInner { struct BackendInner {
client: Arc<Mutex<QobuzClient>>, client: Arc<Mutex<QobuzClient>>,
player: Player, player: Player,
rt: Runtime, rt: Runtime,
cb: EventCallback, cb: EventCallback,
ud: SendPtr, ud: SendPtr,
replaygain_enabled: std::sync::Arc<std::sync::atomic::AtomicBool>,
prefetch: std::sync::Arc<tokio::sync::Mutex<Option<PrefetchedTrack>>>,
} }
pub struct Backend(BackendInner); pub struct Backend(BackendInner);
@@ -88,7 +96,9 @@ pub struct Backend(BackendInner);
// ---------- Helpers ---------- // ---------- Helpers ----------
fn call_cb(cb: EventCallback, ud: SendPtr, ev: c_int, json: &str) { 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()) }; unsafe { cb(ud.0, ev, cstr.as_ptr()) };
} }
@@ -111,8 +121,14 @@ pub unsafe extern "C" fn qobuz_backend_new(
event_cb: EventCallback, event_cb: EventCallback,
userdata: *mut c_void, userdata: *mut c_void,
) -> *mut Backend { ) -> *mut Backend {
let rt = Runtime::new().expect("tokio runtime"); let rt = match Runtime::new() {
let client = Arc::new(Mutex::new(QobuzClient::new().expect("QobuzClient"))); 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(); let player = Player::new();
Box::into_raw(Box::new(Backend(BackendInner { Box::into_raw(Box::new(Backend(BackendInner {
@@ -121,6 +137,8 @@ pub unsafe extern "C" fn qobuz_backend_new(
rt, rt,
cb: event_cb, cb: event_cb,
ud: SendPtr(userdata), 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)),
}))) })))
} }
@@ -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; let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move { 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 { let (ev, json) = match result {
Ok(r) => (EV_ARTIST_OK, serde_json::to_string(&r).unwrap_or_default()), Ok(r) => (EV_ARTIST_OK, serde_json::to_string(&r).unwrap_or_default()),
Err(e) => (EV_ARTIST_ERR, err_json(&e.to_string())), Err(e) => (EV_ARTIST_ERR, err_json(&e.to_string())),
@@ -341,35 +359,58 @@ pub unsafe extern "C" fn qobuz_backend_play_track(
let format = Format::from_id(format_id); let format = Format::from_id(format_id);
let cmd_tx = inner.player.cmd_tx.clone(); let cmd_tx = inner.player.cmd_tx.clone();
let status = inner.player.status.clone(); let status = inner.player.status.clone();
let prefetch = inner.prefetch.clone();
let rg_enabled = inner.replaygain_enabled.clone();
spawn(inner, async move { spawn(inner, async move {
// 1. Track metadata // 1. Check prefetch cache first for zero-gap start
let track = match client.lock().await.get_track(track_id).await { let cached = {
Ok(t) => t, let mut lock = prefetch.lock().await;
Err(e) => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json(&e.to_string())); return; } if lock.as_ref().map(|p| p.track_id == track_id).unwrap_or(false) {
lock.take()
} else {
None
}
}; };
// 2. Stream URL let (track, url) = if let Some(pf) = cached {
let url_dto = match client.lock().await.get_track_url(track_id, format).await { (pf.track, pf.url)
Ok(u) => u, } else {
Err(e) => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json(&e.to_string())); return; } // Fetch track metadata
}; let track = match client.lock().await.get_track(track_id).await {
let url = match url_dto.url { Ok(t) => t,
Some(u) => u, Err(e) => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json(&e.to_string())); return; }
None => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json("no stream URL")); return; } };
// 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; }
};
let url = match url_dto.url {
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) { if let Ok(j) = serde_json::to_string(&track) {
call_cb(cb, ud, EV_TRACK_CHANGED, &j); 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 // 4. Update status + send play command
*status.current_track.lock().unwrap() = Some(track.clone()); *status.current_track.lock().unwrap() = Some(track.clone());
if let Some(dur) = track.duration { if let Some(dur) = track.duration {
status.duration_secs.store(dur as u64, std::sync::atomic::Ordering::Relaxed); 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, format, replaygain_db }));
// 5. State notification // 5. State notification
call_cb(cb, ud, EV_STATE_CHANGED, r#"{"state":"playing"}"#); call_cb(cb, ud, EV_STATE_CHANGED, r#"{"state":"playing"}"#);
@@ -438,6 +479,46 @@ pub unsafe extern "C" fn qobuz_backend_take_track_finished(ptr: *mut Backend) ->
if finished { 1 } else { 0 } 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 ---------- // ---------- Favorites modification ----------
#[no_mangle] #[no_mangle]
@@ -489,3 +570,90 @@ pub unsafe extern "C" fn qobuz_backend_remove_fav_album(ptr: *mut Backend, album
} }
}); });
} }
// ---------- 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);
});
}
// ---------- 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

@@ -15,7 +15,7 @@ use symphonia::core::{
units::Time, 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. /// First 512 KiB of stream kept in memory to support backward seeks during probing.
const HEAD_SIZE: usize = 512 * 1024; const HEAD_SIZE: usize = 512 * 1024;
@@ -127,13 +127,22 @@ impl MediaSource for HttpStreamSource {
} }
} }
/// Stream and decode audio from `url`. Runs on a dedicated OS thread. /// Decode and play `url` inline on the calling thread (the player loop).
pub fn play_track( ///
/// `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, url: &str,
status: &PlayerStatus, status: &PlayerStatus,
stop: &Arc<AtomicBool>,
paused: &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 response = reqwest::blocking::get(url)?;
let content_length = response.content_length(); let content_length = response.content_length();
let source = HttpStreamSource::new(response, content_length); let source = HttpStreamSource::new(response, content_length);
@@ -160,19 +169,91 @@ pub fn play_track(
.make(&track.codec_params, &DecoderOptions::default()) .make(&track.codec_params, &DecoderOptions::default())
.map_err(|e| anyhow::anyhow!("decoder init failed: {e}"))?; .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() {
loop { if ao.sample_rate != sample_rate || ao.channels != channels {
if stop.load(Ordering::SeqCst) { *audio_output = None; // will be recreated below
break;
} }
while paused.load(Ordering::SeqCst) { }
std::thread::sleep(std::time::Duration::from_millis(50)); if audio_output.is_none() {
if stop.load(Ordering::SeqCst) { *audio_output = Some(AudioOutput::try_open(sample_rate, channels)?);
return Ok(()); }
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 {
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::Seek(s)) => {
status.seek_target_secs.store(s, Ordering::Relaxed);
status.seek_requested.load(Ordering::SeqCst); // read-side fence
status.seek_requested.store(true, Ordering::SeqCst);
}
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;
}
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) { if status.seek_requested.load(Ordering::SeqCst) {
status.seek_requested.store(false, Ordering::SeqCst); status.seek_requested.store(false, Ordering::SeqCst);
let target = status.seek_target_secs.load(Ordering::Relaxed); let target = status.seek_target_secs.load(Ordering::Relaxed);
@@ -190,8 +271,10 @@ pub fn play_track(
let packet = match format.next_packet() { let packet = match format.next_packet() {
Ok(p) => p, Ok(p) => p,
Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => { Err(SymphoniaError::IoError(e))
break; if e.kind() == std::io::ErrorKind::UnexpectedEof =>
{
break; // natural end of track
} }
Err(SymphoniaError::ResetRequired) => { Err(SymphoniaError::ResetRequired) => {
decoder.reset(); decoder.reset();
@@ -205,13 +288,16 @@ pub fn play_track(
} }
if let Some(ts) = packet.ts().checked_div(sample_rate as u64) { 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) { match decoder.decode(&packet) {
Ok(decoded) => { Ok(decoded) => {
let volume = status.volume.load(Ordering::Relaxed) as f32 / 100.0; 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::IoError(_)) => break,
Err(SymphoniaError::DecodeError(e)) => eprintln!("decode error: {e}"), Err(SymphoniaError::DecodeError(e)) => eprintln!("decode error: {e}"),
@@ -219,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

@@ -24,6 +24,8 @@ pub struct TrackInfo {
pub track: TrackDto, pub track: TrackDto,
pub url: String, pub url: String,
pub format: Format, pub format: Format,
/// ReplayGain track gain in dB, if enabled and available.
pub replaygain_db: Option<f64>,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@@ -47,6 +49,10 @@ pub struct PlayerStatus {
/// Set by the player loop when a seek command arrives; cleared by the decode thread. /// Set by the player loop when a seek command arrives; cleared by the decode thread.
pub seek_requested: Arc<AtomicBool>, pub seek_requested: Arc<AtomicBool>,
pub seek_target_secs: Arc<AtomicU64>, 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>,
} }
impl PlayerStatus { impl PlayerStatus {
@@ -60,6 +66,8 @@ impl PlayerStatus {
track_finished: Arc::new(AtomicBool::new(false)), track_finished: Arc::new(AtomicBool::new(false)),
seek_requested: Arc::new(AtomicBool::new(false)), seek_requested: Arc::new(AtomicBool::new(false)),
seek_target_secs: Arc::new(AtomicU64::new(0)), seek_target_secs: Arc::new(AtomicU64::new(0)),
replaygain_gain: Arc::new(std::sync::Mutex::new(1.0)),
gapless: Arc::new(AtomicBool::new(false)),
} }
} }
@@ -106,10 +114,6 @@ impl Player {
self.cmd_tx.send(cmd).ok(); 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) { pub fn pause(&self) {
self.send(PlayerCommand::Pause); self.send(PlayerCommand::Pause);
} }
@@ -133,68 +137,80 @@ impl Player {
} }
} }
/// 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) { fn player_loop(rx: std::sync::mpsc::Receiver<PlayerCommand>, status: PlayerStatus) {
let mut stop_flag = Arc::new(AtomicBool::new(true)); use std::sync::mpsc::RecvTimeoutError;
let mut audio_output: Option<output::AudioOutput> = None;
let paused = Arc::new(AtomicBool::new(false)); 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;
loop { 'outer: loop {
match rx.recv_timeout(Duration::from_millis(100)) { // Wait for a Play command (or use one that was interrupted)
Ok(cmd) => match cmd { let info = if let Some(p) = pending_info.take() {
PlayerCommand::Play(info) => { p
stop_flag.store(true, Ordering::SeqCst); } else {
stop_flag = Arc::new(AtomicBool::new(false)); loop {
paused.store(false, Ordering::SeqCst); match rx.recv_timeout(Duration::from_millis(100)) {
Ok(PlayerCommand::Play(info)) => break info,
*status.state.lock().unwrap() = PlayerState::Playing; Ok(PlayerCommand::Stop) => {
*status.current_track.lock().unwrap() = Some(info.track.clone()); audio_output = None;
if let Some(dur) = info.track.duration { paused.store(false, Ordering::SeqCst);
status.duration_secs.store(dur as u64, Ordering::Relaxed); *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);
} }
status.position_secs.store(0, Ordering::Relaxed); Ok(PlayerCommand::SetVolume(v)) => {
status.volume.store(v, Ordering::Relaxed);
}
Ok(PlayerCommand::Seek(s)) => {
status.seek_target_secs.store(s, Ordering::Relaxed);
status.seek_requested.store(true, Ordering::SeqCst);
}
Ok(_) => {} // Pause/Resume ignored when idle
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => break 'outer,
}
}
};
let status_c = status.clone(); // Compute ReplayGain factor
let stop_c = stop_flag.clone(); let rg_factor = info.replaygain_db
let paused_c = paused.clone(); .map(|db| 10f32.powf(db as f32 / 20.0))
.unwrap_or(1.0);
*status.replaygain_gain.lock().unwrap() = rg_factor;
std::thread::spawn(move || { *status.state.lock().unwrap() = PlayerState::Playing;
match decoder::play_track(&info.url, &status_c, &stop_c, &paused_c) { *status.current_track.lock().unwrap() = Some(info.track.clone());
Ok(()) => { if let Some(dur) = info.track.duration {
if !stop_c.load(Ordering::SeqCst) { status.duration_secs.store(dur as u64, Ordering::Relaxed);
*status_c.state.lock().unwrap() = PlayerState::Idle; }
status_c.track_finished.store(true, Ordering::SeqCst); status.position_secs.store(0, Ordering::Relaxed);
} paused.store(false, Ordering::SeqCst);
}
Err(e) => { match decoder::play_track_inline(&info.url, &status, &paused, &mut audio_output, &rx) {
eprintln!("playback error: {e}"); Ok(Some(next_info)) => {
*status_c.state.lock().unwrap() = // Interrupted by a new Play — loop immediately with reused audio output
PlayerState::Error(e.to_string()); 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;
} }
PlayerCommand::Pause => { *status.state.lock().unwrap() = PlayerState::Idle;
paused.store(true, Ordering::SeqCst); status.track_finished.store(true, Ordering::SeqCst);
*status.state.lock().unwrap() = PlayerState::Paused; }
} Err(e) => {
PlayerCommand::Resume => { eprintln!("playback error: {e}");
paused.store(false, Ordering::SeqCst); *status.state.lock().unwrap() = PlayerState::Error(e.to_string());
*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(secs) => {
status.seek_target_secs.store(secs, Ordering::Relaxed);
status.seek_requested.store(true, Ordering::SeqCst);
}
},
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
} }
} }
} }

View File

@@ -15,6 +15,8 @@ const RING_BUFFER_SIZE: usize = 32 * 1024;
pub struct AudioOutput { pub struct AudioOutput {
ring_buf_producer: rb::Producer<f32>, ring_buf_producer: rb::Producer<f32>,
_stream: cpal::Stream, _stream: cpal::Stream,
pub sample_rate: u32,
pub channels: usize,
} }
impl AudioOutput { impl AudioOutput {
@@ -50,6 +52,8 @@ impl AudioOutput {
Ok(Self { Ok(Self {
ring_buf_producer: producer, ring_buf_producer: producer,
_stream: stream, _stream: stream,
sample_rate,
channels,
}) })
} }

View File

@@ -25,6 +25,7 @@ target_sources(qobuz-qt PRIVATE
view/albumlistview.hpp view/albumlistview.hpp
view/artistlistview.hpp view/artistlistview.hpp
view/artistview.hpp view/artistview.hpp
view/artistview.cpp
view/sidepanel/view.hpp view/sidepanel/view.hpp
view/sidepanel/view.cpp view/sidepanel/view.cpp

View File

@@ -8,9 +8,13 @@ QobuzBackend::QobuzBackend(QObject *parent)
: QObject(parent) : QObject(parent)
{ {
m_backend = qobuz_backend_new(&QobuzBackend::eventTrampoline, this); 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 = new QTimer(this);
m_positionTimer->setInterval(500); m_positionTimer->setInterval(50);
connect(m_positionTimer, &QTimer::timeout, this, &QobuzBackend::onPositionTick); connect(m_positionTimer, &QTimer::timeout, this, &QobuzBackend::onPositionTick);
m_positionTimer->start(); m_positionTimer->start();
} }
@@ -35,6 +39,11 @@ void QobuzBackend::setToken(const QString &token)
qobuz_backend_set_token(m_backend, token.toUtf8().constData()); qobuz_backend_set_token(m_backend, token.toUtf8().constData());
} }
void QobuzBackend::getUser()
{
qobuz_backend_get_user(m_backend);
}
// ---- catalog ---- // ---- catalog ----
void QobuzBackend::search(const QString &query, quint32 offset, quint32 limit) void QobuzBackend::search(const QString &query, quint32 offset, quint32 limit)
@@ -79,6 +88,45 @@ void QobuzBackend::getUserPlaylists(quint32 offset, quint32 limit)
qobuz_backend_get_user_playlists(m_backend, offset, 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 ---- // ---- fav modification ----
void QobuzBackend::addFavTrack(qint64 trackId) void QobuzBackend::addFavTrack(qint64 trackId)
@@ -150,7 +198,12 @@ void QobuzBackend::onPositionTick()
void QobuzBackend::onEvent(int eventType, const QString &json) 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) { switch (eventType) {
case EV_LOGIN_OK: case EV_LOGIN_OK:
@@ -201,6 +254,18 @@ void QobuzBackend::onEvent(int eventType, const QString &json)
case EV_STATE_CHANGED: case EV_STATE_CHANGED:
emit stateChanged(obj["state"].toString()); emit stateChanged(obj["state"].toString());
break; 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_GENERIC_ERR:
case EV_TRACK_URL_ERR: case EV_TRACK_URL_ERR:
emit error(obj["error"].toString()); emit error(obj["error"].toString());

View File

@@ -23,6 +23,7 @@ public:
// --- auth --- // --- auth ---
void login(const QString &email, const QString &password); void login(const QString &email, const QString &password);
void setToken(const QString &token); void setToken(const QString &token);
void getUser();
// --- catalog --- // --- catalog ---
void search(const QString &query, quint32 offset = 0, quint32 limit = 20); void search(const QString &query, quint32 offset = 0, quint32 limit = 20);
@@ -36,6 +37,17 @@ public:
void getFavArtists(quint32 offset = 0, quint32 limit = 200); void getFavArtists(quint32 offset = 0, quint32 limit = 200);
void getUserPlaylists(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 --- // --- fav modification ---
void addFavTrack(qint64 trackId); void addFavTrack(qint64 trackId);
void removeFavTrack(qint64 trackId); void removeFavTrack(qint64 trackId);
@@ -60,12 +72,16 @@ signals:
// auth // auth
void loginSuccess(const QString &token, const QJsonObject &user); void loginSuccess(const QString &token, const QJsonObject &user);
void loginError(const QString &error); void loginError(const QString &error);
void userLoaded(const QJsonObject &user);
// catalog // catalog
void searchResult(const QJsonObject &result); void searchResult(const QJsonObject &result);
void albumLoaded(const QJsonObject &album); void albumLoaded(const QJsonObject &album);
void artistLoaded(const QJsonObject &artist); void artistLoaded(const QJsonObject &artist);
void playlistLoaded(const QJsonObject &playlist); void playlistLoaded(const QJsonObject &playlist);
void playlistCreated(const QJsonObject &playlist);
void playlistDeleted(const QJsonObject &result);
void playlistTrackAdded(qint64 playlistId);
// favorites // favorites
void favTracksLoaded(const QJsonObject &result); void favTracksLoaded(const QJsonObject &result);

View File

@@ -36,6 +36,15 @@ SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent)
} }
} }
playLayout->addRow(tr("Preferred quality:"), m_formatBox); 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); layout->addWidget(playGroup);
// --- Last.fm group --- // --- Last.fm group ---
@@ -90,6 +99,8 @@ SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent)
void SettingsDialog::applyChanges() 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().setLastFmEnabled(m_lastFmEnabled->isChecked());
AppSettings::instance().setLastFmApiKey(m_lastFmApiKey->text().trimmed()); AppSettings::instance().setLastFmApiKey(m_lastFmApiKey->text().trimmed());
AppSettings::instance().setLastFmApiSecret(m_lastFmApiSecret->text().trimmed()); AppSettings::instance().setLastFmApiSecret(m_lastFmApiSecret->text().trimmed());

View File

@@ -17,7 +17,9 @@ public:
private: private:
// Playback // Playback
QComboBox *m_formatBox = nullptr; QComboBox *m_formatBox = nullptr;
QCheckBox *m_replayGain = nullptr;
QCheckBox *m_gapless = nullptr;
// Last.fm // Last.fm
QCheckBox *m_lastFmEnabled = nullptr; QCheckBox *m_lastFmEnabled = nullptr;

View File

@@ -3,6 +3,9 @@
#include <QHeaderView> #include <QHeaderView>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonObject> #include <QJsonObject>
#include <QInputDialog>
#include <QMenu>
#include <QMessageBox>
namespace List namespace List
{ {
@@ -10,6 +13,7 @@ namespace List
static constexpr int TypeRole = Qt::UserRole + 1; static constexpr int TypeRole = Qt::UserRole + 1;
static constexpr int IdRole = Qt::UserRole + 2; static constexpr int IdRole = Qt::UserRole + 2;
static constexpr int NameRole = Qt::UserRole + 3; static constexpr int NameRole = Qt::UserRole + 3;
static constexpr int IsOwnerRole = Qt::UserRole + 4;
enum NodeType { enum NodeType {
NodeFavTracks, NodeFavTracks,
@@ -24,16 +28,29 @@ Library::Library(QobuzBackend *backend, QWidget *parent)
{ {
setHeaderHidden(true); setHeaderHidden(true);
setRootIsDecorated(true); setRootIsDecorated(true);
setContextMenuPolicy(Qt::NoContextMenu); setContextMenuPolicy(Qt::CustomContextMenu);
buildStaticNodes(); buildStaticNodes();
connect(m_backend, &QobuzBackend::userPlaylistsLoaded, connect(m_backend, &QobuzBackend::userPlaylistsLoaded,
this, &Library::onUserPlaylistsLoaded); 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, connect(this, &QTreeWidget::itemClicked,
this, &Library::onItemClicked); this, &Library::onItemClicked);
connect(this, &QTreeWidget::itemDoubleClicked, connect(this, &QTreeWidget::itemDoubleClicked,
this, &Library::onItemDoubleClicked); this, &Library::onItemDoubleClicked);
connect(this, &QTreeWidget::customContextMenuRequested,
this, &Library::onContextMenuRequested);
} }
void Library::buildStaticNodes() void Library::buildStaticNodes()
@@ -70,17 +87,75 @@ void Library::onUserPlaylistsLoaded(const QJsonObject &result)
while (m_playlistsNode->childCount() > 0) while (m_playlistsNode->childCount() > 0)
delete m_playlistsNode->takeChild(0); delete m_playlistsNode->takeChild(0);
QVector<QPair<qint64, QString>> editablePlaylists;
const qint64 myUserId = AppSettings::instance().userId();
const QJsonArray items = result["items"].toArray(); const QJsonArray items = result["items"].toArray();
for (const auto &v : items) { for (const auto &v : items) {
const QJsonObject pl = v.toObject(); const QJsonObject pl = v.toObject();
const QString name = pl["name"].toString(); const QString name = pl["name"].toString();
const qint64 id = static_cast<qint64>(pl["id"].toDouble()); 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}); auto *item = new QTreeWidgetItem(m_playlistsNode, QStringList{name});
item->setData(0, TypeRole, NodePlaylist); item->setData(0, TypeRole, NodePlaylist);
item->setData(0, IdRole, id); item->setData(0, IdRole, id);
item->setData(0, NameRole, name); 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) void Library::onItemClicked(QTreeWidgetItem *item, int)
@@ -95,6 +170,7 @@ void Library::onItemClicked(QTreeWidgetItem *item, int)
case NodePlaylist: { case NodePlaylist: {
const qint64 id = item->data(0, IdRole).toLongLong(); const qint64 id = item->data(0, IdRole).toLongLong();
const QString name = item->data(0, NameRole).toString(); const QString name = item->data(0, NameRole).toString();
m_openPlaylistId = id;
emit playlistRequested(id, name); emit playlistRequested(id, name);
break; break;
} }

View File

@@ -1,8 +1,12 @@
#pragma once #pragma once
#include "../backend/qobuzbackend.hpp" #include "../backend/qobuzbackend.hpp"
#include "../util/settings.hpp"
#include <QTreeWidget> #include <QTreeWidget>
#include <QVector>
#include <QPair>
#include <QString>
namespace List namespace List
{ {
@@ -23,17 +27,23 @@ namespace List
void favAlbumsRequested(); void favAlbumsRequested();
void favArtistsRequested(); void favArtistsRequested();
void playlistRequested(qint64 playlistId, const QString &name); 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: private slots:
void onUserPlaylistsLoaded(const QJsonObject &result); void onUserPlaylistsLoaded(const QJsonObject &result);
void onItemClicked(QTreeWidgetItem *item, int column); void onItemClicked(QTreeWidgetItem *item, int column);
void onItemDoubleClicked(QTreeWidgetItem *item, int column); void onItemDoubleClicked(QTreeWidgetItem *item, int column);
void onContextMenuRequested(const QPoint &pos);
private: private:
QobuzBackend *m_backend = nullptr; QobuzBackend *m_backend = nullptr;
QTreeWidgetItem *m_myLibNode = nullptr; QTreeWidgetItem *m_myLibNode = nullptr;
QTreeWidgetItem *m_playlistsNode = nullptr; QTreeWidgetItem *m_playlistsNode = nullptr;
qint64 m_openPlaylistId = 0;
void buildStaticNodes(); void buildStaticNodes();
}; };

View File

@@ -1,4 +1,5 @@
#include "tracks.hpp" #include "tracks.hpp"
#include "../util/settings.hpp"
#include <QHeaderView> #include <QHeaderView>
#include <QMenu> #include <QMenu>
@@ -34,42 +35,103 @@ Tracks::Tracks(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
this, &Tracks::onDoubleClicked); this, &Tracks::onDoubleClicked);
connect(this, &QTreeView::customContextMenuRequested, connect(this, &QTreeView::customContextMenuRequested,
this, &Tracks::onContextMenu); 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) void Tracks::loadTracks(const QJsonArray &tracks)
{ {
setPlaylistContext(0);
setColumnHidden(TrackListModel::ColAlbum, false);
m_model->setTracks(tracks, false, /*useSequential=*/true); m_model->setTracks(tracks, false, /*useSequential=*/true);
} }
void Tracks::loadAlbum(const QJsonObject &album) void Tracks::loadAlbum(const QJsonObject &album)
{ {
setPlaylistContext(0);
setColumnHidden(TrackListModel::ColAlbum, true);
const QJsonArray items = album["tracks"].toObject()["items"].toArray(); const QJsonArray items = album["tracks"].toObject()["items"].toArray();
m_model->setTracks(items); // album: use track_number m_model->setTracks(items); // album: use track_number
} }
void Tracks::loadPlaylist(const QJsonObject &playlist) 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(); const QJsonArray items = playlist["tracks"].toObject()["items"].toArray();
m_model->setTracks(items, /*usePosition=*/true); m_model->setTracks(items, /*usePosition=*/true);
} }
void Tracks::loadSearchTracks(const QJsonArray &tracks) void Tracks::loadSearchTracks(const QJsonArray &tracks)
{ {
setPlaylistContext(0);
setColumnHidden(TrackListModel::ColAlbum, false);
m_model->setTracks(tracks, false, /*useSequential=*/true); 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) void Tracks::setPlayingTrackId(qint64 id)
{ {
m_model->setPlayingId(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) void Tracks::onDoubleClicked(const QModelIndex &index)
{ {
const qint64 id = m_model->data(index, TrackListModel::TrackIdRole).toLongLong(); const qint64 id = m_model->data(index, TrackListModel::TrackIdRole).toLongLong();
if (id > 0) { 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); emit playTrackRequested(id);
} }
} }
@@ -80,6 +142,7 @@ void Tracks::onContextMenu(const QPoint &pos)
if (!index.isValid()) return; if (!index.isValid()) return;
const qint64 id = m_model->data(index, TrackListModel::TrackIdRole).toLongLong(); 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(); const QJsonObject trackJson = m_model->data(index, TrackListModel::TrackJsonRole).toJsonObject();
QMenu menu(this); QMenu menu(this);
@@ -88,12 +151,28 @@ void Tracks::onContextMenu(const QPoint &pos)
auto *playNext = menu.addAction(QIcon(":/res/icons/media-skip-forward.svg"), tr("Play next")); 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")); auto *addQueue = menu.addAction(QIcon(":/res/icons/media-playlist-append.svg"), tr("Add to queue"));
menu.addSeparator(); 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(); const bool isFav = m_model->isFav(id);
connect(playNow, &QAction::triggered, this, [this, id, row] { if (isFav) {
m_queue->setContext(m_model->currentTracksJson(), row); 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); emit playTrackRequested(id);
}); });
connect(playNext, &QAction::triggered, this, [this, trackJson] { connect(playNext, &QAction::triggered, this, [this, trackJson] {
@@ -102,12 +181,6 @@ void Tracks::onContextMenu(const QPoint &pos)
connect(addQueue, &QAction::triggered, this, [this, trackJson] { connect(addQueue, &QAction::triggered, this, [this, trackJson] {
m_queue->addToQueue(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 // Open album
const QString albumId = m_model->trackAt(index.row()).albumId; const QString albumId = m_model->trackAt(index.row()).albumId;
@@ -134,6 +207,36 @@ 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
});
}
}
menu.exec(viewport()->mapToGlobal(pos)); menu.exec(viewport()->mapToGlobal(pos));
} }

View File

@@ -7,6 +7,10 @@
#include <QTreeView> #include <QTreeView>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonObject> #include <QJsonObject>
#include <QVector>
#include <QPair>
#include <QString>
#include <QSet>
namespace List namespace List
{ {
@@ -25,13 +29,34 @@ namespace List
/// Called when the backend fires EV_TRACK_CHANGED so the playing row is highlighted. /// Called when the backend fires EV_TRACK_CHANGED so the playing row is highlighted.
void setPlayingTrackId(qint64 id); 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: signals:
void playTrackRequested(qint64 trackId); void playTrackRequested(qint64 trackId);
void addToPlaylistRequested(qint64 trackId, qint64 playlistId);
void removeFromPlaylistRequested(qint64 playlistId, qint64 playlistTrackId);
private: private:
TrackListModel *m_model = nullptr; TrackListModel *m_model = nullptr;
QobuzBackend *m_backend = nullptr; QobuzBackend *m_backend = nullptr;
PlayQueue *m_queue = 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 onDoubleClicked(const QModelIndex &index);
void onContextMenu(const QPoint &pos); void onContextMenu(const QPoint &pos);

View File

@@ -11,27 +11,67 @@ int main(int argc, char *argv[])
app.setOrganizationName(QStringLiteral("qobuz-qt")); app.setOrganizationName(QStringLiteral("qobuz-qt"));
app.setApplicationVersion(QStringLiteral("0.1.0")); 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"))); app.setStyle(QStyleFactory::create(QStringLiteral("Fusion")));
QPalette darkPalette; QPalette darkPalette;
darkPalette.setColor(QPalette::Window, QColor(35, 35, 35)); darkPalette.setColor(QPalette::Window, QColor(0x19, 0x19, 0x19));
darkPalette.setColor(QPalette::WindowText, Qt::white); darkPalette.setColor(QPalette::WindowText, QColor(0xe8, 0xe8, 0xe8));
darkPalette.setColor(QPalette::Base, QColor(25, 25, 25)); darkPalette.setColor(QPalette::Base, QColor(0x14, 0x14, 0x14));
darkPalette.setColor(QPalette::AlternateBase, QColor(45, 45, 45)); darkPalette.setColor(QPalette::AlternateBase, QColor(0x1e, 0x1e, 0x1e));
darkPalette.setColor(QPalette::ToolTipBase, Qt::white); darkPalette.setColor(QPalette::ToolTipBase, QColor(0x19, 0x19, 0x19));
darkPalette.setColor(QPalette::ToolTipText, Qt::white); darkPalette.setColor(QPalette::ToolTipText, QColor(0xe8, 0xe8, 0xe8));
darkPalette.setColor(QPalette::Text, Qt::white); darkPalette.setColor(QPalette::Text, QColor(0xe8, 0xe8, 0xe8));
darkPalette.setColor(QPalette::Button, QColor(53, 53, 53)); darkPalette.setColor(QPalette::Button, QColor(0x2a, 0x2a, 0x2a));
darkPalette.setColor(QPalette::ButtonText, Qt::white); darkPalette.setColor(QPalette::ButtonText, QColor(0xe8, 0xe8, 0xe8));
darkPalette.setColor(QPalette::BrightText, Qt::red); darkPalette.setColor(QPalette::BrightText, QColor(0xFF, 0xB2, 0x32));
darkPalette.setColor(QPalette::Link, QColor(42, 130, 218)); darkPalette.setColor(QPalette::Link, QColor(0x46, 0xB3, 0xEE)); // Qobuz blue
darkPalette.setColor(QPalette::Highlight, QColor(42, 130, 218)); darkPalette.setColor(QPalette::Highlight, QColor(0xFF, 0xB2, 0x32)); // Qobuz orange
darkPalette.setColor(QPalette::HighlightedText, Qt::black); darkPalette.setColor(QPalette::HighlightedText, QColor(0x10, 0x10, 0x10)); // dark on orange
darkPalette.setColor(QPalette::PlaceholderText, QColor(140, 140, 140)); darkPalette.setColor(QPalette::PlaceholderText, QColor(0x66, 0x66, 0x66));
darkPalette.setColor(QPalette::Disabled, QPalette::Text, QColor(127, 127, 127)); darkPalette.setColor(QPalette::Disabled, QPalette::Text, QColor(0x55, 0x55, 0x55));
darkPalette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor(127, 127, 127)); 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); 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; auto *backend = new QobuzBackend;
MainWindow window(backend); MainWindow window(backend);
window.show(); window.show();

View File

@@ -72,17 +72,43 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
// ---- Backend signals ---- // ---- Backend signals ----
connect(m_backend, &QobuzBackend::loginSuccess, this, &MainWindow::onLoginSuccess); connect(m_backend, &QobuzBackend::loginSuccess, this, &MainWindow::onLoginSuccess);
connect(m_backend, &QobuzBackend::loginError, this, &MainWindow::onLoginError); 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::favTracksLoaded, this, &MainWindow::onFavTracksLoaded);
connect(m_backend, &QobuzBackend::favAlbumsLoaded, this, &MainWindow::onFavAlbumsLoaded); connect(m_backend, &QobuzBackend::favAlbumsLoaded, this, &MainWindow::onFavAlbumsLoaded);
connect(m_backend, &QobuzBackend::favArtistsLoaded, this, &MainWindow::onFavArtistsLoaded); connect(m_backend, &QobuzBackend::favArtistsLoaded, this, &MainWindow::onFavArtistsLoaded);
connect(m_backend, &QobuzBackend::albumLoaded, this, &MainWindow::onAlbumLoaded); connect(m_backend, &QobuzBackend::albumLoaded, this, &MainWindow::onAlbumLoaded);
connect(m_backend, &QobuzBackend::artistLoaded, this, &MainWindow::onArtistLoaded); connect(m_backend, &QobuzBackend::artistLoaded, this, &MainWindow::onArtistLoaded);
connect(m_backend, &QobuzBackend::playlistLoaded, this, &MainWindow::onPlaylistLoaded); 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::trackChanged, this, &MainWindow::onTrackChanged);
connect(m_backend, &QobuzBackend::error, this, [this](const QString &msg) { connect(m_backend, &QobuzBackend::error, this, [this](const QString &msg) {
statusBar()->showMessage(tr("Error: %1").arg(msg), 6000); 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 ---- // ---- Library → backend ----
connect(m_library, &List::Library::favTracksRequested, this, [this] { connect(m_library, &List::Library::favTracksRequested, this, [this] {
m_backend->getFavTracks(); m_backend->getFavTracks();
@@ -102,9 +128,19 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
statusBar()->showMessage(tr("Loading playlist: %1…").arg(name)); statusBar()->showMessage(tr("Loading playlist: %1…").arg(name));
}); });
// ---- Track list → playback ---- // ---- Track list → playback / playlist management ----
connect(m_content->tracksList(), &List::Tracks::playTrackRequested, connect(m_content->tracksList(), &List::Tracks::playTrackRequested,
this, &MainWindow::onPlayTrackRequested); 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 ---- // ---- Search panel ----
connect(m_sidePanel, &SidePanel::View::albumSelected, connect(m_sidePanel, &SidePanel::View::albumSelected,
@@ -129,6 +165,10 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
connect(m_toolBar, &MainToolBar::queueToggled, connect(m_toolBar, &MainToolBar::queueToggled,
this, [this](bool v) { m_queuePanel->setVisible(v); }); this, [this](bool v) { m_queuePanel->setVisible(v); });
// Apply playback options from saved settings
m_backend->setReplayGain(AppSettings::instance().replayGainEnabled());
m_backend->setGapless(AppSettings::instance().gaplessEnabled());
tryRestoreSession(); tryRestoreSession();
} }
@@ -166,7 +206,10 @@ void MainWindow::tryRestoreSession()
const QString token = AppSettings::instance().authToken(); const QString token = AppSettings::instance().authToken();
if (!token.isEmpty()) { if (!token.isEmpty()) {
m_backend->setToken(token); m_backend->setToken(token);
m_library->refresh(); if (AppSettings::instance().userId() == 0)
m_backend->getUser(); // userLoaded will call m_library->refresh()
else
m_library->refresh();
const QString name = AppSettings::instance().displayName(); const QString name = AppSettings::instance().displayName();
statusBar()->showMessage(tr("Signed in as %1").arg( statusBar()->showMessage(tr("Signed in as %1").arg(
name.isEmpty() ? AppSettings::instance().userEmail() : name)); name.isEmpty() ? AppSettings::instance().userEmail() : name));
@@ -210,6 +253,9 @@ void MainWindow::onLoginSuccess(const QString &token, const QJsonObject &user)
const QString email = user["email"].toString(); const QString email = user["email"].toString();
AppSettings::instance().setDisplayName(displayName); AppSettings::instance().setDisplayName(displayName);
AppSettings::instance().setUserEmail(email); 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( statusBar()->showMessage(tr("Signed in as %1").arg(
displayName.isEmpty() ? email : displayName)); displayName.isEmpty() ? email : displayName));
m_library->refresh(); m_library->refresh();
@@ -233,10 +279,29 @@ void MainWindow::onTrackChanged(const QJsonObject &track)
: track["performer"].toObject()["name"].toString(); : track["performer"].toObject()["name"].toString();
statusBar()->showMessage( statusBar()->showMessage(
artist.isEmpty() ? title : QStringLiteral("▶ %1 — %2").arg(artist, title)); 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) 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); m_content->showFavTracks(result);
statusBar()->showMessage( statusBar()->showMessage(
tr("%1 favorite tracks").arg(result["total"].toInt()), 4000); tr("%1 favorite tracks").arg(result["total"].toInt()), 4000);
@@ -298,3 +363,19 @@ void MainWindow::onSearchToggled(bool visible)
{ {
m_sidePanel->setVisible(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);
}

View File

@@ -13,6 +13,11 @@
#include <QMainWindow> #include <QMainWindow>
#include <QDockWidget> #include <QDockWidget>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonArray>
#include <QVector>
#include <QSet>
#include <QPair>
#include <QString>
class MainWindow : public QMainWindow class MainWindow : public QMainWindow
{ {
@@ -38,6 +43,8 @@ private slots:
void onSearchAlbumSelected(const QString &albumId); void onSearchAlbumSelected(const QString &albumId);
void onSearchArtistSelected(qint64 artistId); void onSearchArtistSelected(qint64 artistId);
void onSearchToggled(bool visible); void onSearchToggled(bool visible);
void onPlaylistCreated(const QJsonObject &playlist);
void onUserPlaylistsChanged(const QVector<QPair<qint64, QString>> &playlists);
void showLoginDialog(); void showLoginDialog();
void showSettingsDialog(); void showSettingsDialog();
@@ -45,6 +52,7 @@ private slots:
private: private:
QobuzBackend *m_backend = nullptr; QobuzBackend *m_backend = nullptr;
PlayQueue *m_queue = nullptr; PlayQueue *m_queue = nullptr;
QVector<QPair<qint64, QString>> m_userPlaylists;
MainToolBar *m_toolBar = nullptr; MainToolBar *m_toolBar = nullptr;
MainContent *m_content = nullptr; MainContent *m_content = nullptr;
List::Library *m_library = nullptr; List::Library *m_library = nullptr;

View File

@@ -17,16 +17,27 @@ void TrackListModel::setTracks(const QJsonArray &tracks,
m_tracks.clear(); m_tracks.clear();
m_tracks.reserve(tracks.size()); 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; int seq = 1;
for (const QJsonValue &v : tracks) { for (const QJsonValue &v : tracks) {
const QJsonObject t = v.toObject(); const QJsonObject t = v.toObject();
TrackItem item; TrackItem item;
item.id = static_cast<qint64>(t["id"].toDouble()); item.id = static_cast<qint64>(t["id"].toDouble());
item.title = t["title"].toString(); item.playlistTrackId = static_cast<qint64>(t["playlist_track_id"].toDouble());
item.duration = static_cast<qint64>(t["duration"].toDouble()); item.discNumber = t["media_number"].toInt(1);
item.hiRes = t["hires_streamable"].toBool(); item.duration = static_cast<qint64>(t["duration"].toDouble());
item.streamable = t["streamable"].toBool(true); item.streamable = t["streamable"].toBool(true);
item.raw = t; 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) { if (useSequential) {
item.number = seq++; item.number = seq++;
@@ -47,17 +58,46 @@ void TrackListModel::setTracks(const QJsonArray &tracks,
item.album = album["title"].toString(); item.album = album["title"].toString();
item.albumId = album["id"].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)
if (m_sortColumn >= 0) int maxDisc = 1;
sortData(m_sortColumn, m_sortOrder); 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(); endResetModel();
// Tell external listeners the sorted order is ready (e.g. PlayQueue sync) if (!m_hasMultipleDiscs && m_sortColumn >= 0)
if (m_sortColumn >= 0)
emit sortApplied(); emit sortApplied();
} }
@@ -68,6 +108,44 @@ void TrackListModel::clear()
endResetModel(); 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) void TrackListModel::setPlayingId(qint64 id)
{ {
m_playingId = id; m_playingId = id;
@@ -76,6 +154,23 @@ void TrackListModel::setPlayingId(qint64 id)
{Qt::FontRole, Qt::DecorationRole}); {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 int TrackListModel::rowCount(const QModelIndex &parent) const
{ {
return parent.isValid() ? 0 : m_tracks.size(); return parent.isValid() ? 0 : m_tracks.size();
@@ -92,6 +187,19 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const
return {}; return {};
const TrackItem &t = m_tracks.at(index.row()); 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); const bool isPlaying = (t.id == m_playingId && m_playingId != 0);
if (role == Qt::DisplayRole) { if (role == Qt::DisplayRole) {
@@ -111,17 +219,22 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const
} }
if (role == Qt::ForegroundRole) { if (role == Qt::ForegroundRole) {
if (!t.streamable) return QColor(Qt::gray); if (!t.streamable) return QColor(0x55, 0x55, 0x55);
if (isPlaying) return QColor(0x1d, 0xb9, 0x54); // Qobuz green if (isPlaying) return QColor(0xFF, 0xB2, 0x32); // Qobuz orange
} }
if (role == Qt::DecorationRole && index.column() == ColNumber && isPlaying) { if (role == Qt::DecorationRole && index.column() == ColNumber && isPlaying) {
return QIcon(QStringLiteral(":/res/icons/media-track-show-active.svg")); return QIcon(QStringLiteral(":/res/icons/media-track-show-active.svg"));
} }
if (role == TrackIdRole) return t.id; if (role == Qt::DecorationRole && index.column() == ColTitle && m_favIds.contains(t.id)) {
if (role == TrackJsonRole) return t.raw; return QIcon(QStringLiteral(":/res/icons/starred-symbolic.svg"));
if (role == HiResRole) return t.hiRes; }
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 {}; return {};
} }
@@ -169,7 +282,8 @@ void TrackListModel::sort(int column, Qt::SortOrder order)
m_sortColumn = column; m_sortColumn = column;
m_sortOrder = order; 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(); emit layoutAboutToBeChanged();
sortData(column, order); sortData(column, order);
@@ -180,7 +294,8 @@ void TrackListModel::sort(int column, Qt::SortOrder order)
QString TrackListModel::formatDuration(qint64 secs) QString TrackListModel::formatDuration(qint64 secs)
{ {
const int m = static_cast<int>(secs / 60); if (secs < 0) secs = 0;
const int s = static_cast<int>(secs % 60); const qint64 m = secs / 60;
const qint64 s = secs % 60;
return QStringLiteral("%1:%2").arg(m).arg(s, 2, 10, QLatin1Char('0')); return QStringLiteral("%1:%2").arg(m).arg(s, 2, 10, QLatin1Char('0'));
} }

View File

@@ -4,18 +4,22 @@
#include <QJsonArray> #include <QJsonArray>
#include <QJsonObject> #include <QJsonObject>
#include <QVector> #include <QVector>
#include <QSet>
#include <QFont> #include <QFont>
struct TrackItem { struct TrackItem {
qint64 id = 0; qint64 id = 0;
int number = 0; qint64 playlistTrackId = 0;
int number = 0;
int discNumber = 1;
bool isDiscHeader = false;
QString title; QString title;
QString artist; QString artist;
QString album; QString album;
QString albumId; QString albumId;
qint64 duration = 0; // seconds qint64 duration = 0; // seconds
bool hiRes = false; bool hiRes = false;
bool streamable = false; bool streamable = false;
QJsonObject raw; QJsonObject raw;
}; };
@@ -34,9 +38,10 @@ public:
}; };
enum Role { enum Role {
TrackIdRole = Qt::UserRole + 1, TrackIdRole = Qt::UserRole + 1,
TrackJsonRole = Qt::UserRole + 2, TrackJsonRole = Qt::UserRole + 2,
HiResRole = Qt::UserRole + 3, HiResRole = Qt::UserRole + 3,
PlaylistTrackIdRole = Qt::UserRole + 4,
}; };
explicit TrackListModel(QObject *parent = nullptr); explicit TrackListModel(QObject *parent = nullptr);
@@ -50,14 +55,27 @@ public:
void setPlayingId(qint64 id); void setPlayingId(qint64 id);
qint64 playingId() const { return m_playingId; } 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); } 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 currentTracksJson() const
{ {
QJsonArray out; QJsonArray out;
for (const auto &t : m_tracks) for (const auto &t : m_tracks)
out.append(t.raw); if (!t.isDiscHeader) out.append(t.raw);
return out; return out;
} }
@@ -76,7 +94,9 @@ signals:
private: private:
QVector<TrackItem> m_tracks; QVector<TrackItem> m_tracks;
qint64 m_playingId = 0; QSet<qint64> m_favIds;
qint64 m_playingId = 0;
bool m_hasMultipleDiscs = false;
int m_sortColumn = -1; int m_sortColumn = -1;
Qt::SortOrder m_sortOrder = Qt::AscendingOrder; Qt::SortOrder m_sortOrder = Qt::AscendingOrder;

View File

@@ -25,14 +25,27 @@ public:
{ {
m_queue.clear(); m_queue.clear();
m_playNext.clear(); m_playNext.clear();
for (const auto &v : tracks)
m_queue.append(v.toObject()); // Only queue streamable tracks; find the filtered index for startIndex
if (m_shuffle) { int filteredStart = 0;
shuffleQueue(startIndex); int filteredIdx = 0;
// shuffleQueue moves the start track to index 0 and sets m_index = 0 bool found = false;
} else { for (int orig = 0; orig < tracks.size(); ++orig) {
m_index = qBound(0, startIndex, m_queue.size() - 1); 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(); emit queueChanged();
} }
@@ -41,8 +54,11 @@ public:
void reorderContext(const QJsonArray &tracks, qint64 currentId) void reorderContext(const QJsonArray &tracks, qint64 currentId)
{ {
m_queue.clear(); m_queue.clear();
for (const auto &v : tracks) for (const auto &v : tracks) {
m_queue.append(v.toObject()); const QJsonObject t = v.toObject();
if (t["streamable"].toBool(true))
m_queue.append(t);
}
m_index = 0; m_index = 0;
for (int i = 0; i < m_queue.size(); ++i) { for (int i = 0; i < m_queue.size(); ++i) {
@@ -91,6 +107,14 @@ public:
emit queueChanged(); 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" ---- // ---- Play-next prepend queue (like "Add to queue" ----
void addToQueue(const QJsonObject &track) void addToQueue(const QJsonObject &track)

View File

@@ -21,6 +21,9 @@ public:
QString displayName() const { return m_settings.value("user/display_name").toString(); } QString displayName() const { return m_settings.value("user/display_name").toString(); }
void setDisplayName(const QString &n) { m_settings.setValue("user/display_name", n); } 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 // 5 = MP3, 6 = CD, 7 = HiRes96, 27 = HiRes192
int preferredFormat() const { return m_settings.value("playback/format", 6).toInt(); } int preferredFormat() const { return m_settings.value("playback/format", 6).toInt(); }
void setPreferredFormat(int f) { m_settings.setValue("playback/format", f); } void setPreferredFormat(int f) { m_settings.setValue("playback/format", f); }
@@ -31,6 +34,13 @@ public:
bool rememberLogin() const { return m_settings.value("auth/remember", true).toBool(); } bool rememberLogin() const { return m_settings.value("auth/remember", true).toBool(); }
void setRememberLogin(bool r) { m_settings.setValue("auth/remember", r); } 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 --- // --- Last.fm ---
bool lastFmEnabled() const { return m_settings.value("lastfm/enabled", false).toBool(); } bool lastFmEnabled() const { return m_settings.value("lastfm/enabled", false).toBool(); }
void setLastFmEnabled(bool v) { m_settings.setValue("lastfm/enabled", v); } void setLastFmEnabled(bool v) { m_settings.setValue("lastfm/enabled", v); }

View File

@@ -47,18 +47,32 @@ public:
for (const auto &v : albums) { for (const auto &v : albums) {
const QJsonObject a = v.toObject(); const QJsonObject a = v.toObject();
const QString id = a["id"].toString(); const QString id = a["id"].toString();
const QString title = a["title"].toString(); const QString base = a["title"].toString();
const QString artist = a["artist"].toObject()["name"].toString(); const QString ver = a["version"].toString().trimmed();
const QString date = a["release_date_original"].toString(); const QString title = ver.isEmpty() ? base : base + QStringLiteral(" (") + ver + QLatin1Char(')');
const QString year = date.left(4);
const int tracks = a["tracks_count"].toInt(); // artist.name is either a plain string (old AlbumDto) or {display: ...} (artist/page)
const bool hiRes = a["hires_streamable"].toBool(); const QJsonValue artistNameVal = a["artist"].toObject()["name"];
const QString artist = artistNameVal.isObject()
? artistNameVal.toObject()["display"].toString()
: artistNameVal.toString();
// year: release_date_original (old) or dates.original (artist/page)
const QString date = a["release_date_original"].toString();
const QString year = date.isEmpty()
? a["dates"].toObject()["original"].toString().left(4)
: date.left(4);
const int tracks = a["tracks_count"].toInt();
// hires: flat field (old) or rights.hires_streamable (artist/page)
const bool hiRes = a["hires_streamable"].toBool()
|| a["rights"].toObject()["hires_streamable"].toBool();
auto *item = new QTreeWidgetItem(this); auto *item = new QTreeWidgetItem(this);
if (hiRes) { if (hiRes) {
item->setText(0, QStringLiteral("H")); item->setText(0, QStringLiteral("H"));
item->setForeground(0, QColor(QStringLiteral("#FFD700"))); item->setForeground(0, QColor(QStringLiteral("#FFB232")));
item->setFont(0, hiResFont); item->setFont(0, hiResFont);
item->setTextAlignment(0, Qt::AlignCenter); item->setTextAlignment(0, Qt::AlignCenter);
} }

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

@@ -0,0 +1,185 @@
#include "artistview.hpp"
#include "albumlistview.hpp"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QScrollArea>
#include <QFont>
#include <QJsonValue>
#include <QRegularExpression>
// ---------------------------------------------------------------------------
// ArtistSection
// ---------------------------------------------------------------------------
ArtistSection::ArtistSection(const QString &title, QWidget *parent)
: QWidget(parent)
, m_baseTitle(title)
{
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
m_toggle = new QToolButton(this);
m_toggle->setCheckable(true);
m_toggle->setChecked(true);
m_toggle->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
m_toggle->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
m_toggle->setStyleSheet(QStringLiteral(
"QToolButton { text-align: left; font-weight: bold; padding: 4px 6px;"
" border: none; border-bottom: 1px solid #333; }"
"QToolButton:hover { background: #1e1e1e; }"
));
updateToggleText(0);
layout->addWidget(m_toggle);
m_list = new AlbumListView(this);
layout->addWidget(m_list);
connect(m_toggle, &QToolButton::toggled, m_list, &AlbumListView::setVisible);
connect(m_list, &AlbumListView::albumSelected, this, &ArtistSection::albumSelected);
}
void ArtistSection::setAlbums(const QJsonArray &albums)
{
m_list->setAlbums(albums);
updateToggleText(albums.size());
}
bool ArtistSection::isEmpty() const
{
return m_list->topLevelItemCount() == 0;
}
void ArtistSection::updateToggleText(int count)
{
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);
// Keep arrow in sync when toggled
disconnect(m_toggle, &QToolButton::toggled, nullptr, nullptr);
connect(m_toggle, &QToolButton::toggled, m_list, &AlbumListView::setVisible);
connect(m_toggle, &QToolButton::toggled, this, [this, count](bool open) {
const QString a = open ? QStringLiteral("") : QStringLiteral("");
const QString t = count > 0
? QStringLiteral("%1%2 (%3)").arg(a, m_baseTitle).arg(count)
: a + m_baseTitle;
m_toggle->setText(t);
});
}
// ---------------------------------------------------------------------------
// ArtistView
// ---------------------------------------------------------------------------
ArtistView::ArtistView(QWidget *parent)
: QWidget(parent)
{
auto *outerLayout = new QVBoxLayout(this);
outerLayout->setContentsMargins(8, 8, 8, 8);
outerLayout->setSpacing(6);
m_nameLabel = new QLabel(this);
QFont f = m_nameLabel->font();
f.setPointSize(f.pointSize() + 4);
f.setBold(true);
m_nameLabel->setFont(f);
outerLayout->addWidget(m_nameLabel);
m_bioLabel = new QLabel(this);
m_bioLabel->setWordWrap(true);
m_bioLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft);
m_bioLabel->setMaximumHeight(80);
outerLayout->addWidget(m_bioLabel);
// 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(8);
m_secAlbums = new ArtistSection(tr("Albums"), content);
m_secEps = new ArtistSection(tr("Singles & EPs"), content);
m_secLive = new ArtistSection(tr("Live"), content);
m_secCompilations = new ArtistSection(tr("Compilations"), content);
m_secOther = new ArtistSection(tr("Other"), content);
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);
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)
{
// artist/page: name is {"display": "..."}
m_nameLabel->setText(artist["name"].toObject()["display"].toString());
// biography.content is HTML — strip tags for safe plain-text display
const QString bioHtml = artist["biography"].toObject()["content"].toString();
if (!bioHtml.isEmpty()) {
QString plain = bioHtml;
// Strip HTML entities and tags to prevent rendering injected content
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_bioLabel->setTextFormat(Qt::PlainText);
m_bioLabel->setText(plain);
m_bioLabel->setVisible(!plain.isEmpty());
} else {
m_bioLabel->setVisible(false);
}
// releases is an array of {type, has_more, items[]}
// types we care about: "album", "epSingle", "live"
const QJsonArray releases = artist["releases"].toArray();
QJsonArray albums, eps, live, compilations;
for (const QJsonValue &rv : releases) {
const QJsonObject rg = rv.toObject();
const QString type = rg["type"].toString();
const QJsonArray items = rg["items"].toArray();
if (type == QStringLiteral("album"))
albums = items;
else if (type == QStringLiteral("epSingle"))
eps = items;
else if (type == QStringLiteral("live"))
live = items;
else if (type == QStringLiteral("compilation"))
compilations = items;
}
m_secAlbums->setAlbums(albums);
m_secEps->setAlbums(eps);
m_secLive->setAlbums(live);
m_secCompilations->setAlbums(compilations);
m_secOther->setAlbums({});
m_secAlbums->setVisible(!m_secAlbums->isEmpty());
m_secEps->setVisible(!m_secEps->isEmpty());
m_secLive->setVisible(!m_secLive->isEmpty());
m_secCompilations->setVisible(!m_secCompilations->isEmpty());
m_secOther->setVisible(false);
}

View File

@@ -3,62 +3,54 @@
#include "albumlistview.hpp" #include "albumlistview.hpp"
#include <QWidget> #include <QWidget>
#include <QVBoxLayout>
#include <QLabel> #include <QLabel>
#include <QFont> #include <QToolButton>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonArray> #include <QJsonArray>
/// Artist detail page: name, biography summary, and their album list. class AlbumListView;
class ArtistView : public QWidget
/// One collapsible section (e.g. "Albums", "EPs & Singles") inside ArtistView.
class ArtistSection : public QWidget
{ {
Q_OBJECT Q_OBJECT
public: public:
explicit ArtistView(QWidget *parent = nullptr) : QWidget(parent) explicit ArtistSection(const QString &title, QWidget *parent = nullptr);
{
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(8, 8, 8, 8);
layout->setSpacing(6);
m_nameLabel = new QLabel(this); void setAlbums(const QJsonArray &albums);
QFont f = m_nameLabel->font(); bool isEmpty() const;
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);
}
signals: signals:
void albumSelected(const QString &albumId); void albumSelected(const QString &albumId);
private: private:
QLabel *m_nameLabel = nullptr; QString m_baseTitle;
QLabel *m_bioLabel = nullptr; QToolButton *m_toggle = nullptr;
AlbumListView *m_albums = nullptr; AlbumListView *m_list = nullptr;
void updateToggleText(int count);
};
/// Artist detail page: name, biography, and albums split into collapsible sections
/// (Albums / EPs & Singles / Other) keyed on the release_type field.
class ArtistView : public QWidget
{
Q_OBJECT
public:
explicit ArtistView(QWidget *parent = nullptr);
void setArtist(const QJsonObject &artist);
signals:
void albumSelected(const QString &albumId);
private:
QLabel *m_nameLabel = nullptr;
QLabel *m_bioLabel = nullptr;
ArtistSection *m_secAlbums = nullptr;
ArtistSection *m_secEps = nullptr;
ArtistSection *m_secLive = nullptr;
ArtistSection *m_secCompilations = nullptr;
ArtistSection *m_secOther = nullptr;
}; };

View File

@@ -7,8 +7,6 @@
namespace Context namespace Context
{ {
static constexpr int ArtSize = 96;
View::View(QobuzBackend *backend, QWidget *parent) View::View(QobuzBackend *backend, QWidget *parent)
: QDockWidget(tr("Now Playing"), parent) : QDockWidget(tr("Now Playing"), parent)
, m_backend(backend) , m_backend(backend)
@@ -24,13 +22,8 @@ View::View(QobuzBackend *backend, QWidget *parent)
layout->setContentsMargins(8, 8, 8, 8); layout->setContentsMargins(8, 8, 8, 8);
layout->setSpacing(6); layout->setSpacing(6);
m_albumArt = new QLabel(container); m_albumArt = new ArtWidget(container);
m_albumArt->setFixedSize(ArtSize, ArtSize); layout->addWidget(m_albumArt);
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_title = new QLabel(tr("Not playing"), container); m_title = new QLabel(tr("Not playing"), container);
m_title->setAlignment(Qt::AlignCenter); m_title->setAlignment(Qt::AlignCenter);
@@ -48,7 +41,6 @@ View::View(QobuzBackend *backend, QWidget *parent)
layout->addStretch(); layout->addStretch();
setWidget(container); setWidget(container);
setMinimumWidth(160);
connect(m_backend, &QobuzBackend::trackChanged, this, &View::onTrackChanged); 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_title->setText(title.isEmpty() ? tr("Not playing") : title);
m_artist->setText(artist); m_artist->setText(artist);
// Prefer "large" image, fall back to "small"
const QJsonObject img = track["album"].toObject()["image"].toObject(); const QJsonObject img = track["album"].toObject()["image"].toObject();
QString artUrl = img["large"].toString(); QString artUrl = img["large"].toString();
if (artUrl.isEmpty()) if (artUrl.isEmpty())

View File

@@ -3,13 +3,49 @@
#include "../../backend/qobuzbackend.hpp" #include "../../backend/qobuzbackend.hpp"
#include <QDockWidget> #include <QDockWidget>
#include <QWidget>
#include <QLabel> #include <QLabel>
#include <QPixmap>
#include <QPainter>
#include <QPaintEvent>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QNetworkReply> #include <QNetworkReply>
#include <QJsonObject> #include <QJsonObject>
namespace Context 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 class View : public QDockWidget
{ {
Q_OBJECT Q_OBJECT
@@ -23,7 +59,7 @@ namespace Context
private: private:
QobuzBackend *m_backend = nullptr; QobuzBackend *m_backend = nullptr;
QLabel *m_albumArt = nullptr; ArtWidget *m_albumArt = nullptr;
QLabel *m_title = nullptr; QLabel *m_title = nullptr;
QLabel *m_artist = nullptr; QLabel *m_artist = nullptr;
QNetworkAccessManager *m_nam = nullptr; QNetworkAccessManager *m_nam = nullptr;

View File

@@ -1,6 +1,7 @@
#include "maincontent.hpp" #include "maincontent.hpp"
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QPushButton>
MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *parent) MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
: QWidget(parent) : QWidget(parent)
@@ -19,13 +20,34 @@ MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
this); this);
m_welcome->setAlignment(Qt::AlignCenter); 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_albumList = new AlbumListView(this);
m_artistList = new ArtistListView(this); m_artistList = new ArtistListView(this);
m_artistView = new ArtistView(this); m_artistView = new ArtistView(this);
m_stack->addWidget(m_welcome); // 0 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_albumList); // 2
m_stack->addWidget(m_artistList); // 3 m_stack->addWidget(m_artistList); // 3
m_stack->addWidget(m_artistView); // 4 m_stack->addWidget(m_artistView); // 4
@@ -41,24 +63,28 @@ void MainContent::showWelcome() { m_stack->setCurrentIndex(0); }
void MainContent::showAlbum(const QJsonObject &album) void MainContent::showAlbum(const QJsonObject &album)
{ {
m_header->setAlbum(album);
m_tracks->loadAlbum(album); m_tracks->loadAlbum(album);
m_stack->setCurrentIndex(1); m_stack->setCurrentIndex(1);
} }
void MainContent::showPlaylist(const QJsonObject &playlist) void MainContent::showPlaylist(const QJsonObject &playlist)
{ {
m_header->setPlaylist(playlist);
m_tracks->loadPlaylist(playlist); m_tracks->loadPlaylist(playlist);
m_stack->setCurrentIndex(1); m_stack->setCurrentIndex(1);
} }
void MainContent::showFavTracks(const QJsonObject &result) void MainContent::showFavTracks(const QJsonObject &result)
{ {
m_header->hide();
m_tracks->loadTracks(result["items"].toArray()); m_tracks->loadTracks(result["items"].toArray());
m_stack->setCurrentIndex(1); m_stack->setCurrentIndex(1);
} }
void MainContent::showSearchTracks(const QJsonArray &tracks) void MainContent::showSearchTracks(const QJsonArray &tracks)
{ {
m_header->hide();
m_tracks->loadSearchTracks(tracks); m_tracks->loadSearchTracks(tracks);
m_stack->setCurrentIndex(1); m_stack->setCurrentIndex(1);
} }

View File

@@ -6,6 +6,7 @@
#include "albumlistview.hpp" #include "albumlistview.hpp"
#include "artistlistview.hpp" #include "artistlistview.hpp"
#include "artistview.hpp" #include "artistview.hpp"
#include "trackcontextheader.hpp"
#include <QWidget> #include <QWidget>
#include <QLabel> #include <QLabel>
@@ -36,11 +37,12 @@ signals:
void artistRequested(qint64 artistId); void artistRequested(qint64 artistId);
private: private:
QobuzBackend *m_backend = nullptr; QobuzBackend *m_backend = nullptr;
QStackedWidget *m_stack = nullptr; QStackedWidget *m_stack = nullptr;
QLabel *m_welcome = nullptr; QLabel *m_welcome = nullptr;
List::Tracks *m_tracks = nullptr; List::Tracks *m_tracks = nullptr;
AlbumListView *m_albumList = nullptr; TrackContextHeader *m_header = nullptr;
ArtistListView *m_artistList = nullptr; AlbumListView *m_albumList = nullptr;
ArtistView *m_artistView = nullptr; ArtistListView *m_artistList = nullptr;
ArtistView *m_artistView = nullptr;
}; };

View File

@@ -2,9 +2,8 @@
#include "../util/settings.hpp" #include "../util/settings.hpp"
#include "../model/tracklistmodel.hpp" #include "../model/tracklistmodel.hpp"
#include <QWidget>
#include <QHBoxLayout>
#include <QNetworkRequest> #include <QNetworkRequest>
#include <QResizeEvent>
MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *parent) MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
: QToolBar(parent) : QToolBar(parent)
@@ -19,17 +18,25 @@ MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
m_nam = new QNetworkAccessManager(this); m_nam = new QNetworkAccessManager(this);
connect(m_nam, &QNetworkAccessManager::finished, this, &MainToolBar::onAlbumArtReady); connect(m_nam, &QNetworkAccessManager::finished, this, &MainToolBar::onAlbumArtReady);
// --- Album art thumbnail --- // ---- Album art ----
m_artLabel = new QLabel(this); m_artLabel = new QLabel(this);
m_artLabel->setFixedSize(36, 36); m_artLabel->setFixedSize(36, 36);
m_artLabel->setScaledContents(true); m_artLabel->setScaledContents(true);
m_artLabel->setStyleSheet("border: 1px solid #444; background: #1a1a1a;"); m_artLabel->setStyleSheet("border: 1px solid #444; background: #1a1a1a; border-radius: 3px;");
m_artLabel->setPixmap(QIcon(":/res/icons/view-media-album-cover.svg") m_artLabel->setPixmap(QIcon(":/res/icons/view-media-album-cover.svg").pixmap(32, 32));
.pixmap(32, 32));
addWidget(m_artLabel); 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);
addSeparator(); addSeparator();
// --- Playback controls --- // ---- Media controls ----
m_previous = addAction(Icon::previous(), tr("Previous")); m_previous = addAction(Icon::previous(), tr("Previous"));
connect(m_previous, &QAction::triggered, this, &MainToolBar::onPrevious); connect(m_previous, &QAction::triggered, this, &MainToolBar::onPrevious);
@@ -39,67 +46,72 @@ MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
m_next = addAction(Icon::next(), tr("Next")); m_next = addAction(Icon::next(), tr("Next"));
connect(m_next, &QAction::triggered, this, &MainToolBar::onNext); 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 --- // ---- Progress slider ----
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 ---
m_progress = new ClickableSlider(Qt::Horizontal, this); m_progress = new ClickableSlider(Qt::Horizontal, this);
m_progress->setRange(0, 1000); m_progress->setRange(0, 1000);
m_progress->setValue(0); m_progress->setValue(0);
m_progress->setMinimumWidth(160); m_progress->setMinimumWidth(200);
m_progress->setMaximumWidth(380); m_progress->setMaximumWidth(500);
addWidget(m_progress); addWidget(m_progress);
connect(m_progress, &QSlider::sliderPressed, this, [this] { m_seeking = true; }); connect(m_progress, &QSlider::sliderPressed, this, [this] { m_seeking = true; });
connect(m_progress, &QSlider::sliderReleased, this, &MainToolBar::onProgressReleased); connect(m_progress, &QSlider::sliderReleased, this, &MainToolBar::onProgressReleased);
m_timeLabel = new QLabel("0:00 / 0:00", this); // ---- Position label ----
addWidget(m_timeLabel); 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 = new VolumeButton(this);
m_volume->setValue(AppSettings::instance().volume()); m_volume->setValue(AppSettings::instance().volume());
addWidget(m_volume); addWidget(m_volume);
connect(m_volume, &VolumeButton::volumeChanged, this, &MainToolBar::onVolumeChanged); connect(m_volume, &VolumeButton::volumeChanged, this, &MainToolBar::onVolumeChanged);
addSeparator(); // ---- Queue toggle ----
// --- 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 ---
m_queueBtn = addAction(Icon::queue(), tr("Queue")); m_queueBtn = addAction(Icon::queue(), tr("Queue"));
m_queueBtn->setCheckable(true); m_queueBtn->setCheckable(true);
connect(m_queueBtn, &QAction::toggled, this, &MainToolBar::queueToggled); connect(m_queueBtn, &QAction::toggled, this, &MainToolBar::queueToggled);
// --- Search toggle --- // ---- Search toggle ----
m_search = addAction(Icon::search(), tr("Search")); m_search = addAction(Icon::search(), tr("Search"));
m_search->setCheckable(true); m_search->setCheckable(true);
connect(m_search, &QAction::toggled, this, &MainToolBar::searchToggled); connect(m_search, &QAction::toggled, this, &MainToolBar::searchToggled);
// --- Backend signals --- // ---- Backend signals ----
connect(m_backend, &QobuzBackend::stateChanged, this, &MainToolBar::onBackendStateChanged); connect(m_backend, &QobuzBackend::stateChanged, this, &MainToolBar::onBackendStateChanged);
connect(m_backend, &QobuzBackend::trackChanged, this, &MainToolBar::onTrackChanged); connect(m_backend, &QobuzBackend::trackChanged, this, &MainToolBar::onTrackChanged);
connect(m_backend, &QobuzBackend::positionChanged, this, &MainToolBar::onPositionChanged); connect(m_backend, &QobuzBackend::positionChanged, this, &MainToolBar::onPositionChanged);
connect(m_backend, &QobuzBackend::trackFinished, this, &MainToolBar::onTrackFinished); connect(m_backend, &QobuzBackend::trackFinished, this, &MainToolBar::onTrackFinished);
// --- Queue signals --- // ---- Queue signals ----
connect(m_queue, &PlayQueue::queueChanged, this, &MainToolBar::onQueueChanged); 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 ---- // ---- public ----
@@ -113,20 +125,21 @@ void MainToolBar::setPlaying(bool playing)
void MainToolBar::setCurrentTrack(const QJsonObject &track) void MainToolBar::setCurrentTrack(const QJsonObject &track)
{ {
const QString title = track["title"].toString(); const QString title = track["title"].toString();
const QString artist = track["performer"].toObject()["name"].toString().isEmpty() const QString artist = track["performer"].toObject()["name"].toString().isEmpty()
? track["album"].toObject()["artist"].toObject()["name"].toString() ? track["album"].toObject()["artist"].toObject()["name"].toString()
: track["performer"].toObject()["name"].toString(); : track["performer"].toObject()["name"].toString();
if (title.isEmpty()) { if (title.isEmpty()) {
m_trackLabel->setText(tr("Not playing")); m_trackLabel->setText(tr("Not playing"));
} else if (artist.isEmpty()) { } else if (artist.isEmpty()) {
m_trackLabel->setText(title); m_trackLabel->setText(title.toHtmlEscaped());
} else { } 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(); const QString artUrl = track["album"].toObject()["image"].toObject()["small"].toString();
if (!artUrl.isEmpty() && artUrl != m_currentArtUrl) { if (!artUrl.isEmpty() && artUrl != m_currentArtUrl) {
m_currentArtUrl = artUrl; m_currentArtUrl = artUrl;
@@ -137,12 +150,12 @@ void MainToolBar::setCurrentTrack(const QJsonObject &track)
void MainToolBar::updateProgress(quint64 position, quint64 duration) void MainToolBar::updateProgress(quint64 position, quint64 duration)
{ {
if (m_seeking) return; if (m_seeking) return;
const int sliderPos = duration > 0 const int sliderPos = (duration > 0)
? static_cast<int>(position * 1000 / duration) : 0; ? static_cast<int>(qMin(position * 1000 / duration, quint64(1000))) : 0;
m_progress->blockSignals(true); m_progress->blockSignals(true);
m_progress->setValue(sliderPos); m_progress->setValue(sliderPos);
m_progress->blockSignals(false); m_progress->blockSignals(false);
m_timeLabel->setText( m_position->setText(
QStringLiteral("%1 / %2") QStringLiteral("%1 / %2")
.arg(TrackListModel::formatDuration(static_cast<qint64>(position)), .arg(TrackListModel::formatDuration(static_cast<qint64>(position)),
TrackListModel::formatDuration(static_cast<qint64>(duration)))); TrackListModel::formatDuration(static_cast<qint64>(duration))));
@@ -207,13 +220,12 @@ void MainToolBar::onPositionChanged(quint64 position, quint64 duration)
void MainToolBar::onTrackFinished() void MainToolBar::onTrackFinished()
{ {
// Auto-advance queue
if (m_queue->canGoNext()) { if (m_queue->canGoNext()) {
onNext(); onNext();
} else { } else {
setPlaying(false); setPlaying(false);
m_progress->setValue(0); 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 "../util/icon.hpp"
#include <QToolBar> #include <QToolBar>
#include <QToolButton>
#include <QLabel> #include <QLabel>
#include <QAction> #include <QAction>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
@@ -29,6 +28,9 @@ signals:
void searchToggled(bool visible); void searchToggled(bool visible);
void queueToggled(bool visible); void queueToggled(bool visible);
protected:
void resizeEvent(QResizeEvent *event) override;
private slots: private slots:
void onPlayPause(); void onPlayPause();
void onPrevious(); void onPrevious();
@@ -50,18 +52,19 @@ private:
QobuzBackend *m_backend = nullptr; QobuzBackend *m_backend = nullptr;
PlayQueue *m_queue = nullptr; PlayQueue *m_queue = nullptr;
QLabel *m_artLabel = nullptr; QLabel *m_artLabel = nullptr;
QLabel *m_trackLabel = nullptr; QLabel *m_trackLabel = nullptr;
ClickableSlider *m_progress = nullptr; QAction *m_previous = nullptr;
QLabel *m_timeLabel = nullptr; QAction *m_playPause = nullptr;
VolumeButton *m_volume = nullptr; QAction *m_next = nullptr;
QWidget *m_leftSpacer = nullptr;
QAction *m_previous = nullptr; ClickableSlider *m_progress = nullptr;
QAction *m_playPause = nullptr; QLabel *m_position = nullptr;
QAction *m_next = nullptr; QWidget *m_rightSpacer = nullptr;
QAction *m_shuffle = nullptr; QAction *m_shuffle = nullptr;
QAction *m_queueBtn = nullptr; VolumeButton *m_volume = nullptr;
QAction *m_search = nullptr; QAction *m_queueBtn = nullptr;
QAction *m_search = nullptr;
QNetworkAccessManager *m_nam = nullptr; QNetworkAccessManager *m_nam = nullptr;
QString m_currentArtUrl; QString m_currentArtUrl;

View File

@@ -4,10 +4,119 @@
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QMenu> #include <QMenu>
#include <QAction> #include <QAction>
#include <QPainter>
#include <QStyledItemDelegate>
#include <QApplication>
static constexpr int UpcomingIndexRole = Qt::UserRole + 1; static constexpr int UpcomingIndexRole = Qt::UserRole + 1;
static constexpr int IsPlayNextRole = Qt::UserRole + 2; static constexpr int IsPlayNextRole = Qt::UserRole + 2;
static constexpr int TrackJsonRole = Qt::UserRole + 3; 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) QueuePanel::QueuePanel(PlayQueue *queue, QWidget *parent)
: QDockWidget(tr("Queue"), parent) : QDockWidget(tr("Queue"), parent)
@@ -21,7 +130,6 @@ QueuePanel::QueuePanel(PlayQueue *queue, QWidget *parent)
layout->setContentsMargins(4, 4, 4, 4); layout->setContentsMargins(4, 4, 4, 4);
layout->setSpacing(4); layout->setSpacing(4);
// Header row: count label + Clear button
auto *headerRow = new QHBoxLayout; auto *headerRow = new QHBoxLayout;
m_countLabel = new QLabel(tr("Up next: 0 tracks"), container); m_countLabel = new QLabel(tr("Up next: 0 tracks"), container);
m_clearBtn = new QPushButton(tr("Clear"), container); m_clearBtn = new QPushButton(tr("Clear"), container);
@@ -35,21 +143,17 @@ QueuePanel::QueuePanel(PlayQueue *queue, QWidget *parent)
m_list->setContextMenuPolicy(Qt::CustomContextMenu); m_list->setContextMenuPolicy(Qt::CustomContextMenu);
m_list->setDragDropMode(QAbstractItemView::InternalMove); m_list->setDragDropMode(QAbstractItemView::InternalMove);
m_list->setDefaultDropAction(Qt::MoveAction); m_list->setDefaultDropAction(Qt::MoveAction);
m_list->setItemDelegate(new QueueDelegate(m_list));
layout->addWidget(m_list, 1); layout->addWidget(m_list, 1);
setWidget(container); setWidget(container);
setMinimumWidth(200); setMinimumWidth(200);
connect(m_queue, &PlayQueue::queueChanged, this, &QueuePanel::refresh); connect(m_queue, &PlayQueue::queueChanged, this, &QueuePanel::refresh);
connect(m_clearBtn, &QPushButton::clicked, this, [this] { connect(m_clearBtn, &QPushButton::clicked, this, [this] { m_queue->clearUpcoming(); });
m_queue->clearUpcoming(); connect(m_list, &QListWidget::itemDoubleClicked, this, &QueuePanel::onItemDoubleClicked);
}); connect(m_list, &QListWidget::customContextMenuRequested, this, &QueuePanel::onContextMenu);
connect(m_list, &QListWidget::itemDoubleClicked, connect(m_list->model(), &QAbstractItemModel::rowsMoved, this, &QueuePanel::onRowsMoved);
this, &QueuePanel::onItemDoubleClicked);
connect(m_list, &QListWidget::customContextMenuRequested,
this, &QueuePanel::onContextMenu);
connect(m_list->model(), &QAbstractItemModel::rowsMoved,
this, &QueuePanel::onRowsMoved);
refresh(); refresh();
} }
@@ -69,26 +173,20 @@ void QueuePanel::refresh()
for (int i = 0; i < upcoming.size(); ++i) { for (int i = 0; i < upcoming.size(); ++i) {
const QJsonObject &t = upcoming.at(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() const QString artist = t["performer"].toObject()["name"].toString().isEmpty()
? t["album"].toObject()["artist"].toObject()["name"].toString() ? t["album"].toObject()["artist"].toObject()["name"].toString()
: t["performer"].toObject()["name"].toString(); : t["performer"].toObject()["name"].toString();
const int duration = t["duration"].toInt();
const QString text = artist.isEmpty() auto *item = new QListWidgetItem(title, m_list);
? title
: QStringLiteral("%1 — %2").arg(artist, title);
auto *item = new QListWidgetItem(text, m_list);
item->setData(UpcomingIndexRole, i); item->setData(UpcomingIndexRole, i);
item->setData(IsPlayNextRole, i < playNextCount); item->setData(IsPlayNextRole, i < playNextCount);
item->setData(TrackJsonRole, QVariant::fromValue(t)); item->setData(TrackJsonRole, QVariant::fromValue(t));
item->setData(ArtistRole, artist);
// "Play Next" tracks shown slightly differently item->setData(DurationRole, duration);
if (i < playNextCount) {
QFont f = item->font();
f.setItalic(true);
item->setFont(f);
}
} }
m_refreshing = false; m_refreshing = false;
@@ -109,10 +207,8 @@ void QueuePanel::onRowsMoved()
QVector<QJsonObject> newOrder; QVector<QJsonObject> newOrder;
newOrder.reserve(m_list->count()); newOrder.reserve(m_list->count());
for (int i = 0; i < m_list->count(); ++i) { for (int i = 0; i < m_list->count(); ++i)
const QVariant v = m_list->item(i)->data(TrackJsonRole); newOrder.append(m_list->item(i)->data(TrackJsonRole).value<QJsonObject>());
newOrder.append(v.value<QJsonObject>());
}
m_refreshing = true; m_refreshing = true;
m_queue->setUpcomingOrder(newOrder); m_queue->setUpcomingOrder(newOrder);
@@ -127,15 +223,11 @@ void QueuePanel::onContextMenu(const QPoint &pos)
const int idx = item->data(UpcomingIndexRole).toInt(); const int idx = item->data(UpcomingIndexRole).toInt();
QMenu menu(this); QMenu menu(this);
auto *removeAct = menu.addAction(tr("Remove from queue")); auto *removeAct = menu.addAction(tr("Remove from queue"));
auto *toTopAct = menu.addAction(tr("Move to top (play next)")); auto *toTopAct = menu.addAction(tr("Move to top (play next)"));
connect(removeAct, &QAction::triggered, this, [this, idx] { connect(removeAct, &QAction::triggered, this, [this, idx] { m_queue->removeUpcoming(idx); });
m_queue->removeUpcoming(idx); connect(toTopAct, &QAction::triggered, this, [this, idx] { m_queue->moveUpcomingToTop(idx); });
});
connect(toTopAct, &QAction::triggered, this, [this, idx] {
m_queue->moveUpcomingToTop(idx);
});
menu.exec(m_list->viewport()->mapToGlobal(pos)); menu.exec(m_list->viewport()->mapToGlobal(pos));
} }

View File

@@ -105,7 +105,7 @@ void SearchTab::onSearchResult(const QJsonObject &result)
QStringList{QString(), a["title"].toString(), artist}); QStringList{QString(), a["title"].toString(), artist});
if (hiRes) { if (hiRes) {
item->setText(0, QStringLiteral("H")); item->setText(0, QStringLiteral("H"));
item->setForeground(0, QColor(QStringLiteral("#FFD700"))); item->setForeground(0, QColor(QStringLiteral("#FFB232")));
item->setFont(0, hiResFont); item->setFont(0, hiResFont);
item->setTextAlignment(0, Qt::AlignCenter); item->setTextAlignment(0, Qt::AlignCenter);
} }

View File

@@ -0,0 +1,225 @@
#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)
{
m_title->setText(album["title"].toString());
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 "../util/icon.hpp"
#include <QToolButton> #include <QToolButton>
#include <QWidgetAction> #include <QFrame>
#include <QMenu>
#include <QLabel>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QLabel>
/// A toolbar button that shows a volume slider popup when clicked. /// A toolbar button that shows a volume slider popup when clicked.
class VolumeButton : public QToolButton class VolumeButton : public QToolButton
@@ -17,33 +16,36 @@ class VolumeButton : public QToolButton
public: public:
explicit VolumeButton(QWidget *parent = nullptr) : QToolButton(parent) explicit VolumeButton(QWidget *parent = nullptr) : QToolButton(parent)
{ {
setPopupMode(QToolButton::InstantPopup);
setIcon(Icon::volumeHigh()); setIcon(Icon::volumeHigh());
auto *menu = new QMenu(this); // Qt::Popup closes automatically when the user clicks outside.
auto *widget = new QWidget(menu); m_popup = new QFrame(this, Qt::Popup);
widget->setMinimumWidth(72); m_popup->setFrameShape(QFrame::StyledPanel);
auto *layout = new QVBoxLayout(widget); m_popup->setFrameShadow(QFrame::Raised);
layout->setContentsMargins(6, 6, 6, 6);
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); 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->setRange(0, 100);
m_slider->setValue(80); m_slider->setValue(80);
m_slider->setFixedHeight(120); m_slider->setFixedHeight(120);
layout->addWidget(m_slider, 0, Qt::AlignHCenter);
layout->addWidget(m_label); // Size the popup at its maximum (label = "100%") and lock it
layout->addWidget(m_slider); m_label->setText(QStringLiteral("100%"));
m_popup->adjustSize();
auto *action = new QWidgetAction(menu); m_popup->setFixedSize(m_popup->sizeHint());
action->setDefaultWidget(widget); m_label->setText(QStringLiteral("80%"));
menu->addAction(action);
setMenu(menu);
connect(this, &QToolButton::clicked, this, &VolumeButton::togglePopup);
connect(m_slider, &QSlider::valueChanged, this, [this](int v) { connect(m_slider, &QSlider::valueChanged, this, [this](int v) {
m_label->setText(QString::number(v) + "%"); m_label->setText(QString::number(v) + QStringLiteral("%"));
updateIcon(v); updateIcon(v);
emit volumeChanged(v); emit volumeChanged(v);
}); });
@@ -56,14 +58,31 @@ public:
m_slider->blockSignals(true); m_slider->blockSignals(true);
m_slider->setValue(v); m_slider->setValue(v);
m_slider->blockSignals(false); m_slider->blockSignals(false);
m_label->setText(QString::number(v) + "%"); m_label->setText(QString::number(v) + QStringLiteral("%"));
updateIcon(v); updateIcon(v);
} }
signals: signals:
void volumeChanged(int volume); 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: private:
QFrame *m_popup = nullptr;
ClickableSlider *m_slider = nullptr; ClickableSlider *m_slider = nullptr;
QLabel *m_label = nullptr; QLabel *m_label = nullptr;