22 Commits

Author SHA1 Message Date
joren
75429faffe feat: playlist art fix + Play/Shuffle buttons in context header
- Fix playlist art: API returns images/images150/images300 arrays, not
  image_rectangle; update Rust model and Qt header to use images300 →
  images150 → images with fallback
- Add Play (▶) and Shuffle (⇄) buttons to the album/playlist header;
  Play starts from track 1, Shuffle enables shuffle mode and plays from
  a randomised position

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -30,7 +30,10 @@ 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,
}; };
// Callback signature // Callback signature
@@ -62,12 +65,24 @@ void qobuz_backend_pause(QobuzBackendOpaque *backend);
void qobuz_backend_resume(QobuzBackendOpaque *backend); void qobuz_backend_resume(QobuzBackendOpaque *backend);
void qobuz_backend_stop(QobuzBackendOpaque *backend); void qobuz_backend_stop(QobuzBackendOpaque *backend);
void qobuz_backend_set_volume(QobuzBackendOpaque *backend, uint8_t volume); void qobuz_backend_set_volume(QobuzBackendOpaque *backend, uint8_t volume);
void qobuz_backend_seek(QobuzBackendOpaque *backend, uint64_t position_secs);
uint64_t qobuz_backend_get_position(const QobuzBackendOpaque *backend); uint64_t qobuz_backend_get_position(const QobuzBackendOpaque *backend);
uint64_t qobuz_backend_get_duration(const QobuzBackendOpaque *backend); uint64_t qobuz_backend_get_duration(const QobuzBackendOpaque *backend);
uint8_t qobuz_backend_get_volume(const QobuzBackendOpaque *backend); 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

@@ -89,6 +89,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())]);
@@ -329,6 +338,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

@@ -48,6 +48,7 @@ pub struct TrackDto {
pub title: Option<String>, pub title: 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>,
@@ -182,7 +183,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);
@@ -121,6 +129,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)),
}))) })))
} }
@@ -341,35 +351,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"}"#);
@@ -402,6 +435,11 @@ pub unsafe extern "C" fn qobuz_backend_set_volume(ptr: *mut Backend, volume: u8)
(*ptr).0.player.set_volume(volume); (*ptr).0.player.set_volume(volume);
} }
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_seek(ptr: *mut Backend, position_secs: u64) {
(*ptr).0.player.seek(position_secs);
}
#[no_mangle] #[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_position(ptr: *const Backend) -> u64 { pub unsafe extern "C" fn qobuz_backend_get_position(ptr: *const Backend) -> u64 {
(*ptr).0.player.status.get_position() (*ptr).0.player.status.get_position()
@@ -433,6 +471,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]
@@ -484,3 +562,71 @@ pub unsafe extern "C" fn qobuz_backend_remove_fav_album(ptr: *mut Backend, album
} }
}); });
} }
// ---------- 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

@@ -8,13 +8,14 @@ use std::sync::{
use symphonia::core::{ use symphonia::core::{
codecs::DecoderOptions, codecs::DecoderOptions,
errors::Error as SymphoniaError, errors::Error as SymphoniaError,
formats::FormatOptions, formats::{FormatOptions, SeekMode, SeekTo},
io::{MediaSource, MediaSourceStream}, io::{MediaSource, MediaSourceStream},
meta::MetadataOptions, meta::MetadataOptions,
probe::Hint, probe::Hint,
units::Time,
}; };
use crate::player::{output::AudioOutput, PlayerStatus}; use super::{output::AudioOutput, PlayerCommand, PlayerStatus, TrackInfo};
/// First 512 KiB of stream kept in memory to support backward seeks during probing. /// 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;
@@ -126,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);
@@ -159,23 +169,112 @@ 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) {
status.seek_requested.store(false, Ordering::SeqCst);
let target = status.seek_target_secs.load(Ordering::Relaxed);
let seeked = format.seek(
SeekMode::Coarse,
SeekTo::Time { time: Time::from(target), track_id: None },
);
if let Ok(s) = seeked {
let actual = s.actual_ts / sample_rate as u64;
status.position_secs.store(actual, Ordering::Relaxed);
}
decoder.reset();
continue;
}
let packet = match format.next_packet() { 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();
@@ -189,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}"),
@@ -203,5 +305,10 @@ pub fn play_track(
} }
} }
Ok(()) if stopped {
// On explicit stop, drop the audio output to silence immediately
*audio_output = None;
}
Ok(next_track)
} }

View File

@@ -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)]
@@ -44,6 +46,13 @@ pub struct PlayerStatus {
pub current_track: Arc<std::sync::Mutex<Option<TrackDto>>>, pub current_track: Arc<std::sync::Mutex<Option<TrackDto>>>,
/// Set to true by the decode thread when a track finishes naturally. /// Set to true by the decode thread when a track finishes naturally.
pub track_finished: Arc<AtomicBool>, pub track_finished: Arc<AtomicBool>,
/// Set by the player loop when a seek command arrives; cleared by the decode thread.
pub seek_requested: Arc<AtomicBool>,
pub seek_target_secs: Arc<AtomicU64>,
/// Linear gain factor to apply (1.0 = unity). Updated each time a new track starts.
pub replaygain_gain: Arc<std::sync::Mutex<f32>>,
/// When false the audio output is torn down after each track, producing a gap.
pub gapless: Arc<AtomicBool>,
} }
impl PlayerStatus { impl PlayerStatus {
@@ -55,6 +64,10 @@ impl PlayerStatus {
volume: Arc::new(AtomicU8::new(80)), volume: Arc::new(AtomicU8::new(80)),
current_track: Arc::new(std::sync::Mutex::new(None)), current_track: Arc::new(std::sync::Mutex::new(None)),
track_finished: Arc::new(AtomicBool::new(false)), track_finished: Arc::new(AtomicBool::new(false)),
seek_requested: Arc::new(AtomicBool::new(false)),
seek_target_secs: Arc::new(AtomicU64::new(0)),
replaygain_gain: Arc::new(std::sync::Mutex::new(1.0)),
gapless: Arc::new(AtomicBool::new(false)),
} }
} }
@@ -101,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);
} }
@@ -121,66 +130,87 @@ impl Player {
self.status.volume.store(vol, Ordering::Relaxed); self.status.volume.store(vol, Ordering::Relaxed);
self.send(PlayerCommand::SetVolume(vol)); self.send(PlayerCommand::SetVolume(vol));
} }
pub fn seek(&self, secs: u64) {
self.status.seek_target_secs.store(secs, Ordering::Relaxed);
self.status.seek_requested.store(true, Ordering::SeqCst);
}
} }
/// The player loop runs on a single dedicated OS thread.
/// It owns the `AudioOutput` locally so there are no Send constraints.
/// Decoding is performed inline; the command channel is polled via try_recv
/// inside the decode loop to handle Pause/Resume/Seek/Stop/Play without
/// tearng down and re-opening the audio device between tracks.
fn player_loop(rx: std::sync::mpsc::Receiver<PlayerCommand>, status: PlayerStatus) { 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(_) => {}
},
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

@@ -44,6 +44,9 @@ target_sources(qobuz-qt PRIVATE
dialog/settings.hpp dialog/settings.hpp
dialog/settings.cpp dialog/settings.cpp
# Scrobbler (header-only)
scrobbler/lastfm.hpp
# Widgets (header-only) # Widgets (header-only)
widget/clickableslider.hpp widget/clickableslider.hpp
widget/volumebutton.hpp widget/volumebutton.hpp

View File

@@ -10,7 +10,7 @@ QobuzBackend::QobuzBackend(QObject *parent)
m_backend = qobuz_backend_new(&QobuzBackend::eventTrampoline, this); m_backend = qobuz_backend_new(&QobuzBackend::eventTrampoline, this);
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();
} }
@@ -79,6 +79,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)
@@ -128,6 +167,11 @@ void QobuzBackend::setVolume(int volume)
qobuz_backend_set_volume(m_backend, static_cast<quint8>(qBound(0, volume, 100))); qobuz_backend_set_volume(m_backend, static_cast<quint8>(qBound(0, volume, 100)));
} }
void QobuzBackend::seek(quint64 positionSecs)
{
qobuz_backend_seek(m_backend, positionSecs);
}
quint64 QobuzBackend::position() const { return qobuz_backend_get_position(m_backend); } quint64 QobuzBackend::position() const { return qobuz_backend_get_position(m_backend); }
quint64 QobuzBackend::duration() const { return qobuz_backend_get_duration(m_backend); } quint64 QobuzBackend::duration() const { return qobuz_backend_get_duration(m_backend); }
int QobuzBackend::volume() const { return qobuz_backend_get_volume(m_backend); } int QobuzBackend::volume() const { return qobuz_backend_get_volume(m_backend); }
@@ -196,6 +240,15 @@ 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_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

@@ -36,6 +36,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);
@@ -48,6 +59,7 @@ public:
void resume(); void resume();
void stop(); void stop();
void setVolume(int volume); void setVolume(int volume);
void seek(quint64 positionSecs);
quint64 position() const; quint64 position() const;
quint64 duration() const; quint64 duration() const;
@@ -65,6 +77,9 @@ signals:
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

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

View File

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

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>
@@ -39,31 +40,61 @@ Tracks::Tracks(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
void Tracks::loadTracks(const QJsonArray &tracks) void Tracks::loadTracks(const QJsonArray &tracks)
{ {
setPlaylistContext(0);
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);
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)
{ {
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);
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::playAll(bool shuffle)
{
const QJsonArray tracks = m_model->currentTracksJson();
if (tracks.isEmpty()) return;
m_queue->setShuffle(shuffle);
m_queue->setContext(tracks, 0);
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)
{ {
@@ -134,6 +165,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,9 @@
#include <QTreeView> #include <QTreeView>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonObject> #include <QJsonObject>
#include <QVector>
#include <QPair>
#include <QString>
namespace List namespace List
{ {
@@ -25,13 +28,29 @@ 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);
/// 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,26 +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::Disabled, QPalette::Text, QColor(127, 127, 127)); darkPalette.setColor(QPalette::PlaceholderText, QColor(0x66, 0x66, 0x66));
darkPalette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor(127, 127, 127)); darkPalette.setColor(QPalette::Disabled, QPalette::Text, QColor(0x55, 0x55, 0x55));
darkPalette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor(0x55, 0x55, 0x55));
darkPalette.setColor(QPalette::Mid, QColor(0x2f, 0x2f, 0x2f));
darkPalette.setColor(QPalette::Dark, QColor(0x0e, 0x0e, 0x0e));
app.setPalette(darkPalette); 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

@@ -60,6 +60,15 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
setupMenuBar(); setupMenuBar();
statusBar()->showMessage(tr("Ready")); statusBar()->showMessage(tr("Ready"));
// ---- Scrobbler ----
m_scrobbler = new LastFmScrobbler(this);
connect(m_backend, &QobuzBackend::trackChanged,
m_scrobbler, &LastFmScrobbler::onTrackStarted);
connect(m_backend, &QobuzBackend::positionChanged,
m_scrobbler, &LastFmScrobbler::onPositionChanged);
connect(m_backend, &QobuzBackend::trackFinished,
m_scrobbler, &LastFmScrobbler::onTrackFinished);
// ---- Backend signals ---- // ---- 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);
@@ -68,12 +77,31 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
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();
@@ -93,9 +121,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,
@@ -111,11 +149,19 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
connect(m_content, &MainContent::artistRequested, connect(m_content, &MainContent::artistRequested,
this, &MainWindow::onSearchArtistSelected); this, &MainWindow::onSearchArtistSelected);
// ---- Queue panel ----
connect(m_queuePanel, &QueuePanel::skipToTrackRequested,
this, &MainWindow::onPlayTrackRequested);
// ---- Toolbar toggles ---- // ---- Toolbar toggles ----
connect(m_toolBar, &MainToolBar::searchToggled, this, &MainWindow::onSearchToggled); connect(m_toolBar, &MainToolBar::searchToggled, this, &MainWindow::onSearchToggled);
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();
} }
@@ -197,6 +243,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();
@@ -220,6 +269,16 @@ 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)
@@ -285,3 +344,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

@@ -8,10 +8,14 @@
#include "view/queuepanel.hpp" #include "view/queuepanel.hpp"
#include "view/sidepanel/view.hpp" #include "view/sidepanel/view.hpp"
#include "list/library.hpp" #include "list/library.hpp"
#include "scrobbler/lastfm.hpp"
#include <QMainWindow> #include <QMainWindow>
#include <QDockWidget> #include <QDockWidget>
#include <QJsonObject> #include <QJsonObject>
#include <QVector>
#include <QPair>
#include <QString>
class MainWindow : public QMainWindow class MainWindow : public QMainWindow
{ {
@@ -37,6 +41,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();
@@ -44,6 +50,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;
@@ -51,6 +58,7 @@ private:
QueuePanel *m_queuePanel = nullptr; QueuePanel *m_queuePanel = nullptr;
SidePanel::View *m_sidePanel = nullptr; SidePanel::View *m_sidePanel = nullptr;
QDockWidget *m_libraryDock = nullptr; QDockWidget *m_libraryDock = nullptr;
LastFmScrobbler *m_scrobbler = nullptr;
void setupMenuBar(); void setupMenuBar();
void tryRestoreSession(); void tryRestoreSession();

View File

@@ -21,9 +21,10 @@ void TrackListModel::setTracks(const QJsonArray &tracks,
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.title = t["title"].toString();
item.duration = static_cast<qint64>(t["duration"].toDouble());
item.hiRes = t["hires_streamable"].toBool(); item.hiRes = t["hires_streamable"].toBool();
item.streamable = t["streamable"].toBool(true); item.streamable = t["streamable"].toBool(true);
item.raw = t; item.raw = t;
@@ -68,6 +69,14 @@ 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::setPlayingId(qint64 id) void TrackListModel::setPlayingId(qint64 id)
{ {
m_playingId = id; m_playingId = id;
@@ -111,17 +120,18 @@ 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 == TrackIdRole) return t.id;
if (role == TrackJsonRole) return t.raw; if (role == TrackJsonRole) return t.raw;
if (role == HiResRole) return t.hiRes; if (role == HiResRole) return t.hiRes;
if (role == PlaylistTrackIdRole) return t.playlistTrackId;
return {}; return {};
} }

View File

@@ -7,15 +7,16 @@
#include <QFont> #include <QFont>
struct TrackItem { struct TrackItem {
qint64 id = 0; qint64 id = 0;
int number = 0; qint64 playlistTrackId = 0;
int number = 0;
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 +35,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,6 +52,9 @@ public:
void setPlayingId(qint64 id); void setPlayingId(qint64 id);
qint64 playingId() const { return m_playingId; } qint64 playingId() const { return m_playingId; }
/// 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.

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) {
@@ -184,6 +200,39 @@ public:
int totalSize() const { return m_playNext.size() + m_queue.size(); } int totalSize() const { return m_playNext.size() + m_queue.size(); }
int currentIndex() const { return m_index; } int currentIndex() const { return m_index; }
/// Skip to upcoming[upcomingIndex]: removes everything before it, pops and returns it.
QJsonObject skipToUpcoming(int upcomingIndex)
{
// Remove items 0..upcomingIndex-1 from the front of upcoming
for (int i = 0; i < upcomingIndex; ++i) {
if (!m_playNext.isEmpty())
m_playNext.removeFirst();
else if (m_index + 1 < m_queue.size())
++m_index;
}
// Pop and return the target (now at upcoming[0])
if (!m_playNext.isEmpty()) {
const QJsonObject t = m_playNext.takeFirst();
emit queueChanged();
return t;
}
if (m_index + 1 < m_queue.size()) {
++m_index;
emit queueChanged();
return m_queue.at(m_index);
}
emit queueChanged();
return {};
}
/// Replace the upcoming list with a new order (used after drag-reorder in UI).
void setUpcomingOrder(const QVector<QJsonObject> &newOrder)
{
m_playNext = newOrder;
m_queue.resize(m_index + 1); // drop old main-queue tail
emit queueChanged();
}
/// Move an upcoming item (by its index in upcomingTracks()) to the front of playNext. /// Move an upcoming item (by its index in upcomingTracks()) to the front of playNext.
void moveUpcomingToTop(int upcomingIndex) void moveUpcomingToTop(int upcomingIndex)
{ {

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

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

View File

@@ -21,6 +21,9 @@ public:
QString displayName() const { return m_settings.value("user/display_name").toString(); } 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,26 @@ 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 ---
bool lastFmEnabled() const { return m_settings.value("lastfm/enabled", false).toBool(); }
void setLastFmEnabled(bool v) { m_settings.setValue("lastfm/enabled", v); }
QString lastFmApiKey() const { return m_settings.value("lastfm/api_key").toString(); }
void setLastFmApiKey(const QString &v) { m_settings.setValue("lastfm/api_key", v); }
QString lastFmApiSecret() const { return m_settings.value("lastfm/api_secret").toString(); }
void setLastFmApiSecret(const QString &v) { m_settings.setValue("lastfm/api_secret", v); }
QString lastFmSessionKey() const { return m_settings.value("lastfm/session_key").toString(); }
void setLastFmSessionKey(const QString &v){ m_settings.setValue("lastfm/session_key", v); }
private: private:
AppSettings() : m_settings("qobuz-qt", "qobuz-qt") {} AppSettings() : m_settings("qobuz-qt", "qobuz-qt") {}
QSettings m_settings; QSettings m_settings;

View File

@@ -3,11 +3,13 @@
#include <QTreeWidget> #include <QTreeWidget>
#include <QTreeWidgetItem> #include <QTreeWidgetItem>
#include <QHeaderView> #include <QHeaderView>
#include <QFont>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonArray> #include <QJsonArray>
/// A simple list of albums (used for fav albums and artist detail pages). /// A simple list of albums (used for fav albums and artist detail pages).
/// Double-clicking an item emits albumSelected(albumId). /// Double-clicking an item emits albumSelected(albumId).
/// Column 0 shows a small gold "H" for hi-res streamable albums.
class AlbumListView : public QTreeWidget class AlbumListView : public QTreeWidget
{ {
Q_OBJECT Q_OBJECT
@@ -15,22 +17,23 @@ class AlbumListView : public QTreeWidget
public: public:
explicit AlbumListView(QWidget *parent = nullptr) : QTreeWidget(parent) explicit AlbumListView(QWidget *parent = nullptr) : QTreeWidget(parent)
{ {
setColumnCount(4); setColumnCount(5);
setHeaderLabels({tr("Title"), tr("Artist"), tr("Year"), tr("Tracks")}); setHeaderLabels({tr(""), tr("Title"), tr("Artist"), tr("Year"), tr("Tracks")});
setRootIsDecorated(false); setRootIsDecorated(false);
setAlternatingRowColors(true); setAlternatingRowColors(true);
setSelectionBehavior(QAbstractItemView::SelectRows); setSelectionBehavior(QAbstractItemView::SelectRows);
setSortingEnabled(true); setSortingEnabled(true);
header()->setStretchLastSection(false); header()->setStretchLastSection(false);
header()->setSectionResizeMode(0, QHeaderView::Stretch); header()->setSectionResizeMode(0, QHeaderView::ResizeToContents); // H column
header()->setSectionResizeMode(1, QHeaderView::Stretch); header()->setSectionResizeMode(1, QHeaderView::Stretch);
header()->setSectionResizeMode(2, QHeaderView::ResizeToContents); header()->setSectionResizeMode(2, QHeaderView::Stretch);
header()->setSectionResizeMode(3, QHeaderView::ResizeToContents); header()->setSectionResizeMode(3, QHeaderView::ResizeToContents);
header()->setSectionResizeMode(4, QHeaderView::ResizeToContents);
connect(this, &QTreeWidget::itemDoubleClicked, connect(this, &QTreeWidget::itemDoubleClicked,
this, [this](QTreeWidgetItem *item, int) { this, [this](QTreeWidgetItem *item, int) {
const QString id = item->data(0, Qt::UserRole).toString(); const QString id = item->data(1, Qt::UserRole).toString();
if (!id.isEmpty()) emit albumSelected(id); if (!id.isEmpty()) emit albumSelected(id);
}); });
} }
@@ -38,6 +41,10 @@ public:
void setAlbums(const QJsonArray &albums) void setAlbums(const QJsonArray &albums)
{ {
clear(); clear();
QFont hiResFont;
hiResFont.setBold(true);
hiResFont.setPointSizeF(hiResFont.pointSizeF() * 0.85);
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();
@@ -46,13 +53,20 @@ public:
const QString date = a["release_date_original"].toString(); const QString date = a["release_date_original"].toString();
const QString year = date.left(4); const QString year = date.left(4);
const int tracks = a["tracks_count"].toInt(); const int tracks = a["tracks_count"].toInt();
const bool hiRes = a["hires_streamable"].toBool();
auto *item = new QTreeWidgetItem(this); auto *item = new QTreeWidgetItem(this);
item->setText(0, title); if (hiRes) {
item->setText(1, artist); item->setText(0, QStringLiteral("H"));
item->setText(2, year); item->setForeground(0, QColor(QStringLiteral("#FFB232")));
item->setText(3, tracks > 0 ? QString::number(tracks) : QString()); item->setFont(0, hiResFont);
item->setData(0, Qt::UserRole, id); item->setTextAlignment(0, Qt::AlignCenter);
}
item->setText(1, title);
item->setText(2, artist);
item->setText(3, year);
item->setText(4, tracks > 0 ? QString::number(tracks) : QString());
item->setData(1, Qt::UserRole, id);
} }
} }

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)
@@ -25,12 +23,11 @@ View::View(QobuzBackend *backend, QWidget *parent)
layout->setSpacing(6); layout->setSpacing(6);
m_albumArt = new QLabel(container); m_albumArt = new QLabel(container);
m_albumArt->setFixedSize(ArtSize, ArtSize);
m_albumArt->setScaledContents(true);
m_albumArt->setAlignment(Qt::AlignCenter); m_albumArt->setAlignment(Qt::AlignCenter);
m_albumArt->setStyleSheet(QStringLiteral( m_albumArt->setStyleSheet(QStringLiteral(
"background: #1a1a1a; border-radius: 4px;")); "background: #1a1a1a; border-radius: 4px;"));
layout->addWidget(m_albumArt, 0, Qt::AlignCenter); m_albumArt->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
layout->addWidget(m_albumArt);
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);
@@ -80,9 +77,26 @@ void View::onArtReady(QNetworkReply *reply)
reply->deleteLater(); reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) if (reply->error() != QNetworkReply::NoError)
return; return;
QPixmap pix; if (m_artPixmap.loadFromData(reply->readAll()))
if (pix.loadFromData(reply->readAll())) scaleArtToWidth();
m_albumArt->setPixmap(pix); }
void View::resizeEvent(QResizeEvent *event)
{
QDockWidget::resizeEvent(event);
if (m_artPixmap.isNull()) return;
// Use the new dock width from the event so we don't lag behind the layout
const int side = qMax(32, event->size().width() - 16);
m_albumArt->setFixedHeight(side);
m_albumArt->setPixmap(m_artPixmap.scaled(side, side, Qt::KeepAspectRatio, Qt::SmoothTransformation));
}
void View::scaleArtToWidth()
{
if (m_artPixmap.isNull()) return;
const int side = qMax(32, width() - 16);
m_albumArt->setFixedHeight(side);
m_albumArt->setPixmap(m_artPixmap.scaled(side, side, Qt::KeepAspectRatio, Qt::SmoothTransformation));
} }
} // namespace Context } // namespace Context

View File

@@ -4,6 +4,8 @@
#include <QDockWidget> #include <QDockWidget>
#include <QLabel> #include <QLabel>
#include <QPixmap>
#include <QResizeEvent>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QNetworkReply> #include <QNetworkReply>
#include <QJsonObject> #include <QJsonObject>
@@ -21,12 +23,18 @@ namespace Context
void onTrackChanged(const QJsonObject &track); void onTrackChanged(const QJsonObject &track);
void onArtReady(QNetworkReply *reply); void onArtReady(QNetworkReply *reply);
protected:
void resizeEvent(QResizeEvent *event) override;
private: private:
void scaleArtToWidth();
QobuzBackend *m_backend = nullptr; QobuzBackend *m_backend = nullptr;
QLabel *m_albumArt = nullptr; QLabel *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;
QString m_currentArtUrl; QString m_currentArtUrl;
QPixmap m_artPixmap;
}; };
} // namespace Context } // namespace Context

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,29 @@ 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); });
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 +58,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;
@@ -142,7 +155,7 @@ void MainToolBar::updateProgress(quint64 position, quint64 duration)
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))));
@@ -177,6 +190,11 @@ void MainToolBar::onNext()
void MainToolBar::onProgressReleased() void MainToolBar::onProgressReleased()
{ {
m_seeking = false; m_seeking = false;
const quint64 dur = m_backend->duration();
if (dur > 0) {
const quint64 target = dur * static_cast<quint64>(m_progress->value()) / 1000;
m_backend->seek(target);
}
} }
void MainToolBar::onVolumeChanged(int volume) void MainToolBar::onVolumeChanged(int volume)
@@ -202,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,9 +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 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)
@@ -20,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);
@@ -32,25 +141,28 @@ QueuePanel::QueuePanel(PlayQueue *queue, QWidget *parent)
m_list = new QListWidget(container); m_list = new QListWidget(container);
m_list->setAlternatingRowColors(true); m_list->setAlternatingRowColors(true);
m_list->setContextMenuPolicy(Qt::CustomContextMenu); m_list->setContextMenuPolicy(Qt::CustomContextMenu);
m_list->setDragDropMode(QAbstractItemView::InternalMove);
m_list->setDefaultDropAction(Qt::MoveAction);
m_list->setItemDelegate(new QueueDelegate(m_list));
layout->addWidget(m_list, 1); 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);
refresh(); refresh();
} }
void QueuePanel::refresh() void QueuePanel::refresh()
{ {
if (m_refreshing) return;
m_refreshing = true;
m_list->clear(); m_list->clear();
const QVector<QJsonObject> upcoming = m_queue->upcomingTracks(); const QVector<QJsonObject> upcoming = m_queue->upcomingTracks();
@@ -65,28 +177,40 @@ void QueuePanel::refresh()
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));
// "Play Next" tracks shown slightly differently item->setData(ArtistRole, artist);
if (i < playNextCount) { item->setData(DurationRole, duration);
QFont f = item->font();
f.setItalic(true);
item->setFont(f);
}
} }
m_refreshing = false;
} }
void QueuePanel::onItemDoubleClicked(QListWidgetItem *item) void QueuePanel::onItemDoubleClicked(QListWidgetItem *item)
{ {
// Double-clicking an upcoming item is not needed for now (could skip to it later) const int idx = item->data(UpcomingIndexRole).toInt();
Q_UNUSED(item) const QJsonObject track = m_queue->skipToUpcoming(idx);
if (track.isEmpty()) return;
const qint64 id = static_cast<qint64>(track["id"].toDouble());
emit skipToTrackRequested(id);
}
void QueuePanel::onRowsMoved()
{
if (m_refreshing) return;
QVector<QJsonObject> newOrder;
newOrder.reserve(m_list->count());
for (int i = 0; i < m_list->count(); ++i)
newOrder.append(m_list->item(i)->data(TrackJsonRole).value<QJsonObject>());
m_refreshing = true;
m_queue->setUpcomingOrder(newOrder);
m_refreshing = false;
} }
void QueuePanel::onContextMenu(const QPoint &pos) void QueuePanel::onContextMenu(const QPoint &pos)
@@ -97,15 +221,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

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

View File

@@ -3,6 +3,8 @@
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QPushButton> #include <QPushButton>
#include <QHeaderView>
#include <QFont>
#include <QJsonArray> #include <QJsonArray>
static constexpr int IdRole = Qt::UserRole + 1; static constexpr int IdRole = Qt::UserRole + 1;
@@ -38,8 +40,12 @@ SearchTab::SearchTab(QobuzBackend *backend, QWidget *parent)
m_trackResults->setRootIsDecorated(false); m_trackResults->setRootIsDecorated(false);
m_albumResults = new QTreeWidget(this); m_albumResults = new QTreeWidget(this);
m_albumResults->setHeaderLabels({tr("Album"), tr("Artist")}); m_albumResults->setHeaderLabels({tr(""), tr("Album"), tr("Artist")});
m_albumResults->setRootIsDecorated(false); m_albumResults->setRootIsDecorated(false);
m_albumResults->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
m_albumResults->header()->setSectionResizeMode(1, QHeaderView::Stretch);
m_albumResults->header()->setSectionResizeMode(2, QHeaderView::Stretch);
m_albumResults->header()->setStretchLastSection(false);
m_artistResults = new QTreeWidget(this); m_artistResults = new QTreeWidget(this);
m_artistResults->setHeaderLabels({tr("Artist")}); m_artistResults->setHeaderLabels({tr("Artist")});
@@ -84,14 +90,28 @@ void SearchTab::onSearchResult(const QJsonObject &result)
// Populate albums // Populate albums
m_albumResults->clear(); m_albumResults->clear();
const QJsonArray albums = result["albums"].toObject()["items"].toArray(); {
for (const auto &v : albums) { QFont hiResFont;
const QJsonObject a = v.toObject(); hiResFont.setBold(true);
const QString artist = a["artist"].toObject()["name"].toString(); hiResFont.setPointSizeF(hiResFont.pointSizeF() * 0.85);
auto *item = new QTreeWidgetItem(m_albumResults,
QStringList{a["title"].toString(), artist}); const QJsonArray albums = result["albums"].toObject()["items"].toArray();
item->setData(0, IdRole, a["id"].toString()); for (const auto &v : albums) {
item->setData(0, TypeRole, QStringLiteral("album")); const QJsonObject a = v.toObject();
const QString artist = a["artist"].toObject()["name"].toString();
const bool hiRes = a["hires_streamable"].toBool();
auto *item = new QTreeWidgetItem(m_albumResults,
QStringList{QString(), a["title"].toString(), artist});
if (hiRes) {
item->setText(0, QStringLiteral("H"));
item->setForeground(0, QColor(QStringLiteral("#FFB232")));
item->setFont(0, hiResFont);
item->setTextAlignment(0, Qt::AlignCenter);
}
item->setData(0, TypeRole, QStringLiteral("album")); // handler reads col 0
item->setData(1, IdRole, a["id"].toString());
}
} }
// Populate artists // Populate artists
@@ -114,7 +134,7 @@ void SearchTab::onItemDoubleClicked(QTreeWidgetItem *item, int)
if (type == QStringLiteral("track")) { if (type == QStringLiteral("track")) {
emit trackPlayRequested(item->data(0, IdRole).toLongLong()); emit trackPlayRequested(item->data(0, IdRole).toLongLong());
} else if (type == QStringLiteral("album")) { } else if (type == QStringLiteral("album")) {
emit albumSelected(item->data(0, IdRole).toString()); emit albumSelected(item->data(1, IdRole).toString());
} else if (type == QStringLiteral("artist")) { } else if (type == QStringLiteral("artist")) {
emit artistSelected(item->data(0, IdRole).toLongLong()); emit artistSelected(item->data(0, IdRole).toLongLong());
} }

View File

@@ -0,0 +1,209 @@
#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 QLabel(info);
QFont sf = m_subtitle->font();
sf.setPointSize(sf.pointSize() + 1);
m_subtitle->setFont(sf);
m_subtitle->setWordWrap(true);
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);
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; }
void setAlbum(const QJsonObject &album)
{
m_title->setText(album["title"].toString());
m_subtitle->setText(album["artist"].toObject()["name"].toString());
m_meta->setText(buildAlbumMeta(album));
fetchArt(album["image"].toObject());
show();
}
void setPlaylist(const QJsonObject &playlist)
{
m_title->setText(playlist["name"].toString());
const QString desc = playlist["description"].toString();
const QString owner = playlist["owner"].toObject()["name"].toString();
m_subtitle->setText(desc.isEmpty() ? owner : desc);
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;
QLabel *m_subtitle = nullptr;
QLabel *m_meta = nullptr;
QPushButton *m_playBtn = nullptr;
QPushButton *m_shuffleBtn = nullptr;
QNetworkAccessManager *m_nam = nullptr;
QString m_currentArtUrl;
};