From f30e2058c1546f1622265c17a3e52a0b4c78cef7 Mon Sep 17 00:00:00 2001 From: joren Date: Tue, 24 Mar 2026 11:19:32 +0100 Subject: [PATCH 01/10] feat: playlist management, gapless playback, ReplayGain, Qobuz theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- rust/include/qobuz_backend.h | 14 ++- rust/src/api/client.rs | 58 +++++++++++++ rust/src/api/models.rs | 1 + rust/src/lib.rs | 161 ++++++++++++++++++++++++++++++++--- rust/src/player/decoder.rs | 129 +++++++++++++++++++++++----- rust/src/player/mod.rs | 130 +++++++++++++++------------- rust/src/player/output.rs | 4 + src/backend/qobuzbackend.cpp | 42 ++++++++- src/backend/qobuzbackend.hpp | 12 +++ src/dialog/settings.cpp | 11 +++ src/dialog/settings.hpp | 4 +- src/list/library.cpp | 68 ++++++++++++++- src/list/library.hpp | 9 ++ src/list/tracks.cpp | 45 ++++++++++ src/list/tracks.hpp | 12 +++ src/main.cpp | 74 ++++++++++++---- src/mainwindow.cpp | 52 ++++++++++- src/mainwindow.hpp | 6 ++ src/model/tracklistmodel.cpp | 26 ++++-- src/model/tracklistmodel.hpp | 21 +++-- src/util/settings.hpp | 7 ++ src/view/albumlistview.hpp | 2 +- src/view/queuepanel.cpp | 2 +- src/view/sidepanel/view.cpp | 2 +- 24 files changed, 757 insertions(+), 135 deletions(-) diff --git a/rust/include/qobuz_backend.h b/rust/include/qobuz_backend.h index 7ddba03..ecf32c5 100644 --- a/rust/include/qobuz_backend.h +++ b/rust/include/qobuz_backend.h @@ -30,7 +30,9 @@ enum QobuzEvent { EV_POSITION = 16, EV_TRACK_URL_OK = 17, EV_TRACK_URL_ERR = 18, - EV_GENERIC_ERR = 19, + EV_GENERIC_ERR = 19, + EV_PLAYLIST_CREATED = 20, + EV_PLAYLIST_DELETED = 21, }; // Callback signature @@ -69,6 +71,16 @@ uint8_t qobuz_backend_get_volume(const QobuzBackendOpaque *backend); int qobuz_backend_get_state(const QobuzBackendOpaque *backend); int qobuz_backend_take_track_finished(QobuzBackendOpaque *backend); +// ReplayGain / Gapless +void qobuz_backend_set_replaygain(QobuzBackendOpaque *backend, bool enabled); +void qobuz_backend_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 void qobuz_backend_add_fav_track(QobuzBackendOpaque *backend, int64_t track_id); void qobuz_backend_remove_fav_track(QobuzBackendOpaque *backend, int64_t track_id); diff --git a/rust/src/api/client.rs b/rust/src/api/client.rs index e2defed..37ab507 100644 --- a/rust/src/api/client.rs +++ b/rust/src/api/client.rs @@ -89,6 +89,15 @@ impl QobuzClient { Ok(body) } + fn post_request(&self, method: &str) -> reqwest::RequestBuilder { + let mut builder = self.http.post(self.url(method)); + builder = builder.query(&[("app_id", self.app_id.as_str())]); + if let Some(token) = &self.auth_token { + builder = builder.header("Authorization", format!("Bearer {}", token)); + } + builder + } + fn get_request(&self, method: &str) -> reqwest::RequestBuilder { let mut builder = self.http.get(self.url(method)); builder = builder.query(&[("app_id", self.app_id.as_str())]); @@ -329,6 +338,55 @@ impl QobuzClient { Ok(serde_json::from_value(body["artists"].clone())?) } + // --- Playlist management --- + + pub async fn create_playlist(&self, name: &str) -> Result { + let resp = self + .post_request("playlist/create") + .form(&[("name", name), ("is_public", "false"), ("is_collaborative", "false")]) + .send() + .await?; + let body = Self::check_response(resp).await?; + Ok(serde_json::from_value(body)?) + } + + pub async fn add_track_to_playlist(&self, playlist_id: i64, track_id: i64) -> Result<()> { + let resp = self + .post_request("playlist/addTracks") + .form(&[ + ("playlist_id", playlist_id.to_string()), + ("track_ids", track_id.to_string()), + ("no_duplicate", "true".to_string()), + ]) + .send() + .await?; + Self::check_response(resp).await?; + Ok(()) + } + + pub async fn delete_playlist(&self, playlist_id: i64) -> Result<()> { + let resp = self + .get_request("playlist/delete") + .query(&[("playlist_id", &playlist_id.to_string())]) + .send() + .await?; + Self::check_response(resp).await?; + Ok(()) + } + + pub async fn delete_track_from_playlist(&self, playlist_id: i64, playlist_track_id: i64) -> Result<()> { + let resp = self + .post_request("playlist/deleteTracks") + .form(&[ + ("playlist_id", playlist_id.to_string()), + ("playlist_track_ids", playlist_track_id.to_string()), + ]) + .send() + .await?; + Self::check_response(resp).await?; + Ok(()) + } + pub async fn add_fav_track(&self, track_id: i64) -> Result<()> { let resp = self .get_request("favorite/create") diff --git a/rust/src/api/models.rs b/rust/src/api/models.rs index a2c9f55..2d8e2c4 100644 --- a/rust/src/api/models.rs +++ b/rust/src/api/models.rs @@ -48,6 +48,7 @@ pub struct TrackDto { pub title: Option, pub duration: Option, pub track_number: Option, + pub playlist_track_id: Option, pub album: Option, pub performer: Option, pub composer: Option, diff --git a/rust/src/lib.rs b/rust/src/lib.rs index ad41159..09601fd 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -75,12 +75,20 @@ pub type EventCallback = unsafe extern "C" fn(*mut c_void, c_int, *const c_char) // ---------- Backend ---------- +struct PrefetchedTrack { + track_id: i64, + track: api::models::TrackDto, + url: String, +} + struct BackendInner { client: Arc>, player: Player, rt: Runtime, cb: EventCallback, ud: SendPtr, + replaygain_enabled: std::sync::Arc, + prefetch: std::sync::Arc>>, } pub struct Backend(BackendInner); @@ -121,6 +129,8 @@ pub unsafe extern "C" fn qobuz_backend_new( rt, cb: event_cb, ud: SendPtr(userdata), + replaygain_enabled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + prefetch: std::sync::Arc::new(tokio::sync::Mutex::new(None)), }))) } @@ -341,35 +351,58 @@ pub unsafe extern "C" fn qobuz_backend_play_track( let format = Format::from_id(format_id); let cmd_tx = inner.player.cmd_tx.clone(); let status = inner.player.status.clone(); + let prefetch = inner.prefetch.clone(); + let rg_enabled = inner.replaygain_enabled.clone(); spawn(inner, async move { - // 1. Track metadata - let track = match client.lock().await.get_track(track_id).await { - Ok(t) => t, - Err(e) => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json(&e.to_string())); return; } + // 1. Check prefetch cache first for zero-gap start + let cached = { + let mut lock = prefetch.lock().await; + if lock.as_ref().map(|p| p.track_id == track_id).unwrap_or(false) { + lock.take() + } else { + None + } }; - // 2. 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; } + let (track, url) = if let Some(pf) = cached { + (pf.track, pf.url) + } else { + // Fetch track metadata + let track = match client.lock().await.get_track(track_id).await { + Ok(t) => t, + Err(e) => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json(&e.to_string())); return; } + }; + // 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) { call_cb(cb, ud, EV_TRACK_CHANGED, &j); } + // 3. Compute ReplayGain if enabled + let replaygain_db = if rg_enabled.load(std::sync::atomic::Ordering::Relaxed) { + track.audio_info.as_ref().and_then(|ai| ai.replaygain_track_gain) + } else { + None + }; + // 4. Update status + send play command *status.current_track.lock().unwrap() = Some(track.clone()); if let Some(dur) = track.duration { status.duration_secs.store(dur as u64, std::sync::atomic::Ordering::Relaxed); } - let _ = cmd_tx.send(player::PlayerCommand::Play(player::TrackInfo { track, url, format })); + let _ = cmd_tx.send(player::PlayerCommand::Play(player::TrackInfo { track, url, format, replaygain_db })); // 5. State notification call_cb(cb, ud, EV_STATE_CHANGED, r#"{"state":"playing"}"#); @@ -438,6 +471,41 @@ pub unsafe extern "C" fn qobuz_backend_take_track_finished(ptr: *mut Backend) -> if finished { 1 } else { 0 } } +// ---------- ReplayGain / Gapless ---------- + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_set_replaygain(ptr: *mut Backend, enabled: bool) { + (*ptr).0.replaygain_enabled.store(enabled, std::sync::atomic::Ordering::Relaxed); +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_prefetch_track( + ptr: *mut Backend, + track_id: i64, + format_id: i32, +) { + let inner = &(*ptr).0; + let client = inner.client.clone(); + let prefetch = inner.prefetch.clone(); + let format = Format::from_id(format_id); + + spawn(inner, async move { + let track = match client.lock().await.get_track(track_id).await { + Ok(t) => t, + Err(_) => return, + }; + let url_dto = match client.lock().await.get_track_url(track_id, format).await { + Ok(u) => u, + Err(_) => return, + }; + let url = match url_dto.url { + Some(u) => u, + None => return, + }; + *prefetch.lock().await = Some(PrefetchedTrack { track_id, track, url }); + }); +} + // ---------- Favorites modification ---------- #[no_mangle] @@ -489,3 +557,68 @@ 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; + +#[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 { + if let Err(e) = client.lock().await.add_track_to_playlist(playlist_id, track_id).await { + 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())); + } + }); +} diff --git a/rust/src/player/decoder.rs b/rust/src/player/decoder.rs index 7de0e20..f9bf434 100644 --- a/rust/src/player/decoder.rs +++ b/rust/src/player/decoder.rs @@ -15,7 +15,7 @@ use symphonia::core::{ units::Time, }; -use crate::player::{output::AudioOutput, PlayerStatus}; +use super::{output::AudioOutput, PlayerCommand, PlayerStatus, TrackInfo}; /// First 512 KiB of stream kept in memory to support backward seeks during probing. const HEAD_SIZE: usize = 512 * 1024; @@ -127,13 +127,22 @@ impl MediaSource for HttpStreamSource { } } -/// Stream and decode audio from `url`. Runs on a dedicated OS thread. -pub fn play_track( +/// Decode and play `url` inline on the calling thread (the player loop). +/// +/// `audio_output` is reused across calls if the sample rate and channel count match, +/// keeping the CPAL stream open between tracks for gapless playback. +/// +/// Returns: +/// - `Ok(Some(TrackInfo))` — a new Play command arrived; start that track next. +/// - `Ok(None)` — track finished naturally or was stopped. +/// - `Err(_)` — unrecoverable playback error. +pub fn play_track_inline( url: &str, status: &PlayerStatus, - stop: &Arc, paused: &Arc, -) -> Result<()> { + audio_output: &mut Option, + cmd_rx: &std::sync::mpsc::Receiver, +) -> Result> { let response = reqwest::blocking::get(url)?; let content_length = response.content_length(); let source = HttpStreamSource::new(response, content_length); @@ -160,19 +169,91 @@ pub fn play_track( .make(&track.codec_params, &DecoderOptions::default()) .map_err(|e| anyhow::anyhow!("decoder init failed: {e}"))?; - let mut audio_output = AudioOutput::try_open(sample_rate, channels)?; - - loop { - if stop.load(Ordering::SeqCst) { - break; + // Reuse existing audio output if format matches; rebuild only on format change. + if let Some(ao) = audio_output.as_ref() { + if ao.sample_rate != sample_rate || ao.channels != channels { + *audio_output = None; // will be recreated below } - while paused.load(Ordering::SeqCst) { - std::thread::sleep(std::time::Duration::from_millis(50)); - if stop.load(Ordering::SeqCst) { - return Ok(()); + } + if audio_output.is_none() { + *audio_output = Some(AudioOutput::try_open(sample_rate, channels)?); + } + let ao = audio_output.as_mut().unwrap(); + + let mut stopped = false; + let mut next_track: Option = 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); @@ -190,8 +271,10 @@ pub fn play_track( let packet = match format.next_packet() { Ok(p) => p, - Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => { - break; + Err(SymphoniaError::IoError(e)) + if e.kind() == std::io::ErrorKind::UnexpectedEof => + { + break; // natural end of track } Err(SymphoniaError::ResetRequired) => { decoder.reset(); @@ -205,13 +288,16 @@ pub fn play_track( } if let Some(ts) = packet.ts().checked_div(sample_rate as u64) { - status.position_secs.store(ts, std::sync::atomic::Ordering::Relaxed); + status.position_secs.store(ts, Ordering::Relaxed); } match decoder.decode(&packet) { Ok(decoded) => { let volume = status.volume.load(Ordering::Relaxed) as f32 / 100.0; - audio_output.write(decoded, volume, stop)?; + let rg = *status.replaygain_gain.lock().unwrap(); + // Use a stop flag tied to new-track-incoming so write doesn't block + let dummy_stop = Arc::new(AtomicBool::new(false)); + ao.write(decoded, (volume * rg).min(1.0), &dummy_stop)?; } Err(SymphoniaError::IoError(_)) => break, Err(SymphoniaError::DecodeError(e)) => eprintln!("decode error: {e}"), @@ -219,5 +305,10 @@ pub fn play_track( } } - Ok(()) + if stopped { + // On explicit stop, drop the audio output to silence immediately + *audio_output = None; + } + + Ok(next_track) } diff --git a/rust/src/player/mod.rs b/rust/src/player/mod.rs index 3b12ff6..b28f69c 100644 --- a/rust/src/player/mod.rs +++ b/rust/src/player/mod.rs @@ -24,6 +24,8 @@ pub struct TrackInfo { pub track: TrackDto, pub url: String, pub format: Format, + /// ReplayGain track gain in dB, if enabled and available. + pub replaygain_db: Option, } #[derive(Debug, Clone, PartialEq)] @@ -47,6 +49,8 @@ pub struct PlayerStatus { /// Set by the player loop when a seek command arrives; cleared by the decode thread. pub seek_requested: Arc, pub seek_target_secs: Arc, + /// Linear gain factor to apply (1.0 = unity). Updated each time a new track starts. + pub replaygain_gain: Arc>, } impl PlayerStatus { @@ -60,6 +64,7 @@ impl PlayerStatus { 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)), } } @@ -106,10 +111,6 @@ impl Player { self.cmd_tx.send(cmd).ok(); } - pub fn play_track(&self, track: TrackDto, url: String, format: Format) { - self.send(PlayerCommand::Play(TrackInfo { track, url, format })); - } - pub fn pause(&self) { self.send(PlayerCommand::Pause); } @@ -133,68 +134,77 @@ impl Player { } } +/// The player loop runs on a single dedicated OS thread. +/// It owns the `AudioOutput` locally so there are no Send constraints. +/// Decoding is performed inline; the command channel is polled via try_recv +/// inside the decode loop to handle Pause/Resume/Seek/Stop/Play without +/// tearng down and re-opening the audio device between tracks. fn player_loop(rx: std::sync::mpsc::Receiver, status: PlayerStatus) { - let mut stop_flag = Arc::new(AtomicBool::new(true)); + use std::sync::mpsc::RecvTimeoutError; + + let mut audio_output: Option = None; let paused = Arc::new(AtomicBool::new(false)); + // pending_info holds a Play command that interrupted an ongoing decode + let mut pending_info: Option = None; - loop { - match rx.recv_timeout(Duration::from_millis(100)) { - Ok(cmd) => match cmd { - PlayerCommand::Play(info) => { - stop_flag.store(true, Ordering::SeqCst); - stop_flag = Arc::new(AtomicBool::new(false)); - paused.store(false, Ordering::SeqCst); - - *status.state.lock().unwrap() = PlayerState::Playing; - *status.current_track.lock().unwrap() = Some(info.track.clone()); - if let Some(dur) = info.track.duration { - status.duration_secs.store(dur as u64, Ordering::Relaxed); + 'outer: loop { + // Wait for a Play command (or use one that was interrupted) + let info = if let Some(p) = pending_info.take() { + p + } else { + loop { + match rx.recv_timeout(Duration::from_millis(100)) { + Ok(PlayerCommand::Play(info)) => break info, + Ok(PlayerCommand::Stop) => { + audio_output = None; + paused.store(false, Ordering::SeqCst); + *status.state.lock().unwrap() = PlayerState::Idle; + *status.current_track.lock().unwrap() = None; + status.position_secs.store(0, Ordering::Relaxed); + status.duration_secs.store(0, Ordering::Relaxed); } - 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(); - let stop_c = stop_flag.clone(); - let paused_c = paused.clone(); + // Compute ReplayGain factor + let rg_factor = info.replaygain_db + .map(|db| 10f32.powf(db as f32 / 20.0)) + .unwrap_or(1.0); + *status.replaygain_gain.lock().unwrap() = rg_factor; - std::thread::spawn(move || { - match decoder::play_track(&info.url, &status_c, &stop_c, &paused_c) { - Ok(()) => { - if !stop_c.load(Ordering::SeqCst) { - *status_c.state.lock().unwrap() = PlayerState::Idle; - status_c.track_finished.store(true, Ordering::SeqCst); - } - } - Err(e) => { - eprintln!("playback error: {e}"); - *status_c.state.lock().unwrap() = - PlayerState::Error(e.to_string()); - } - } - }); - } - PlayerCommand::Pause => { - paused.store(true, Ordering::SeqCst); - *status.state.lock().unwrap() = PlayerState::Paused; - } - PlayerCommand::Resume => { - paused.store(false, Ordering::SeqCst); - *status.state.lock().unwrap() = PlayerState::Playing; - } - PlayerCommand::Stop => { - stop_flag.store(true, Ordering::SeqCst); - *status.state.lock().unwrap() = PlayerState::Idle; - *status.current_track.lock().unwrap() = None; - status.position_secs.store(0, Ordering::Relaxed); - status.duration_secs.store(0, Ordering::Relaxed); - } - PlayerCommand::SetVolume(_) => {} - PlayerCommand::Seek(secs) => { - status.seek_target_secs.store(secs, Ordering::Relaxed); - status.seek_requested.store(true, Ordering::SeqCst); - } - }, - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + *status.state.lock().unwrap() = PlayerState::Playing; + *status.current_track.lock().unwrap() = Some(info.track.clone()); + if let Some(dur) = info.track.duration { + status.duration_secs.store(dur as u64, Ordering::Relaxed); + } + status.position_secs.store(0, Ordering::Relaxed); + paused.store(false, Ordering::SeqCst); + + match decoder::play_track_inline(&info.url, &status, &paused, &mut audio_output, &rx) { + Ok(Some(next_info)) => { + // Interrupted by a new Play — loop immediately with reused audio output + pending_info = Some(next_info); + } + Ok(None) => { + // Track finished naturally + *status.state.lock().unwrap() = PlayerState::Idle; + status.track_finished.store(true, Ordering::SeqCst); + } + Err(e) => { + eprintln!("playback error: {e}"); + *status.state.lock().unwrap() = PlayerState::Error(e.to_string()); + } } } } diff --git a/rust/src/player/output.rs b/rust/src/player/output.rs index d9ca4fa..b140df8 100644 --- a/rust/src/player/output.rs +++ b/rust/src/player/output.rs @@ -15,6 +15,8 @@ const RING_BUFFER_SIZE: usize = 32 * 1024; pub struct AudioOutput { ring_buf_producer: rb::Producer, _stream: cpal::Stream, + pub sample_rate: u32, + pub channels: usize, } impl AudioOutput { @@ -50,6 +52,8 @@ impl AudioOutput { Ok(Self { ring_buf_producer: producer, _stream: stream, + sample_rate, + channels, }) } diff --git a/src/backend/qobuzbackend.cpp b/src/backend/qobuzbackend.cpp index 5646c18..16fbb7f 100644 --- a/src/backend/qobuzbackend.cpp +++ b/src/backend/qobuzbackend.cpp @@ -10,7 +10,7 @@ QobuzBackend::QobuzBackend(QObject *parent) m_backend = qobuz_backend_new(&QobuzBackend::eventTrampoline, this); m_positionTimer = new QTimer(this); - m_positionTimer->setInterval(500); + m_positionTimer->setInterval(50); connect(m_positionTimer, &QTimer::timeout, this, &QobuzBackend::onPositionTick); m_positionTimer->start(); } @@ -79,6 +79,40 @@ void QobuzBackend::getUserPlaylists(quint32 offset, quint32 limit) qobuz_backend_get_user_playlists(m_backend, offset, limit); } +// ---- playback options ---- + +void QobuzBackend::setReplayGain(bool enabled) +{ + qobuz_backend_set_replaygain(m_backend, enabled); +} + +void QobuzBackend::prefetchTrack(qint64 trackId, int formatId) +{ + qobuz_backend_prefetch_track(m_backend, trackId, formatId); +} + +// ---- playlist management ---- + +void QobuzBackend::createPlaylist(const QString &name) +{ + qobuz_backend_create_playlist(m_backend, name.toUtf8().constData()); +} + +void QobuzBackend::deletePlaylist(qint64 playlistId) +{ + qobuz_backend_delete_playlist(m_backend, playlistId); +} + +void QobuzBackend::addTrackToPlaylist(qint64 playlistId, qint64 trackId) +{ + qobuz_backend_add_track_to_playlist(m_backend, playlistId, trackId); +} + +void QobuzBackend::deleteTrackFromPlaylist(qint64 playlistId, qint64 playlistTrackId) +{ + qobuz_backend_delete_track_from_playlist(m_backend, playlistId, playlistTrackId); +} + // ---- fav modification ---- void QobuzBackend::addFavTrack(qint64 trackId) @@ -201,6 +235,12 @@ void QobuzBackend::onEvent(int eventType, const QString &json) case EV_STATE_CHANGED: emit stateChanged(obj["state"].toString()); break; + case 20: // EV_PLAYLIST_CREATED + emit playlistCreated(obj); + break; + case 21: // EV_PLAYLIST_DELETED + emit playlistDeleted(obj); + break; case EV_GENERIC_ERR: case EV_TRACK_URL_ERR: emit error(obj["error"].toString()); diff --git a/src/backend/qobuzbackend.hpp b/src/backend/qobuzbackend.hpp index 725a522..48ef4b0 100644 --- a/src/backend/qobuzbackend.hpp +++ b/src/backend/qobuzbackend.hpp @@ -36,6 +36,16 @@ public: void getFavArtists(quint32 offset = 0, quint32 limit = 200); void getUserPlaylists(quint32 offset = 0, quint32 limit = 200); + // --- playback options --- + void setReplayGain(bool enabled); + void prefetchTrack(qint64 trackId, int formatId = 6); + + // --- playlist management --- + void createPlaylist(const QString &name); + void deletePlaylist(qint64 playlistId); + void addTrackToPlaylist(qint64 playlistId, qint64 trackId); + void deleteTrackFromPlaylist(qint64 playlistId, qint64 playlistTrackId); + // --- fav modification --- void addFavTrack(qint64 trackId); void removeFavTrack(qint64 trackId); @@ -66,6 +76,8 @@ signals: void albumLoaded(const QJsonObject &album); void artistLoaded(const QJsonObject &artist); void playlistLoaded(const QJsonObject &playlist); + void playlistCreated(const QJsonObject &playlist); + void playlistDeleted(const QJsonObject &result); // favorites void favTracksLoaded(const QJsonObject &result); diff --git a/src/dialog/settings.cpp b/src/dialog/settings.cpp index f3766d9..135d541 100644 --- a/src/dialog/settings.cpp +++ b/src/dialog/settings.cpp @@ -36,6 +36,15 @@ SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent) } } 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 (pre-fetch next track)"), playGroup); + m_gapless->setChecked(AppSettings::instance().gaplessEnabled()); + playLayout->addRow(m_gapless); + layout->addWidget(playGroup); // --- Last.fm group --- @@ -90,6 +99,8 @@ SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent) void SettingsDialog::applyChanges() { AppSettings::instance().setPreferredFormat(m_formatBox->currentData().toInt()); + AppSettings::instance().setReplayGainEnabled(m_replayGain->isChecked()); + AppSettings::instance().setGaplessEnabled(m_gapless->isChecked()); AppSettings::instance().setLastFmEnabled(m_lastFmEnabled->isChecked()); AppSettings::instance().setLastFmApiKey(m_lastFmApiKey->text().trimmed()); AppSettings::instance().setLastFmApiSecret(m_lastFmApiSecret->text().trimmed()); diff --git a/src/dialog/settings.hpp b/src/dialog/settings.hpp index dc04606..10ff78b 100644 --- a/src/dialog/settings.hpp +++ b/src/dialog/settings.hpp @@ -17,7 +17,9 @@ public: private: // Playback - QComboBox *m_formatBox = nullptr; + QComboBox *m_formatBox = nullptr; + QCheckBox *m_replayGain = nullptr; + QCheckBox *m_gapless = nullptr; // Last.fm QCheckBox *m_lastFmEnabled = nullptr; diff --git a/src/list/library.cpp b/src/list/library.cpp index 68f2097..288779b 100644 --- a/src/list/library.cpp +++ b/src/list/library.cpp @@ -3,6 +3,9 @@ #include #include #include +#include +#include +#include namespace List { @@ -24,16 +27,29 @@ Library::Library(QobuzBackend *backend, QWidget *parent) { setHeaderHidden(true); setRootIsDecorated(true); - setContextMenuPolicy(Qt::NoContextMenu); + setContextMenuPolicy(Qt::CustomContextMenu); buildStaticNodes(); connect(m_backend, &QobuzBackend::userPlaylistsLoaded, this, &Library::onUserPlaylistsLoaded); + connect(m_backend, &QobuzBackend::playlistCreated, + this, [this](const QJsonObject &) { refresh(); }); + connect(m_backend, &QobuzBackend::playlistDeleted, + this, [this](const QJsonObject &result) { + const qint64 deletedId = static_cast(result["playlist_id"].toDouble()); + if (deletedId == m_openPlaylistId) { + m_openPlaylistId = 0; + emit openPlaylistDeleted(); + } + refresh(); + }); connect(this, &QTreeWidget::itemClicked, this, &Library::onItemClicked); connect(this, &QTreeWidget::itemDoubleClicked, this, &Library::onItemDoubleClicked); + connect(this, &QTreeWidget::customContextMenuRequested, + this, &Library::onContextMenuRequested); } void Library::buildStaticNodes() @@ -70,6 +86,7 @@ void Library::onUserPlaylistsLoaded(const QJsonObject &result) while (m_playlistsNode->childCount() > 0) delete m_playlistsNode->takeChild(0); + QVector> playlists; const QJsonArray items = result["items"].toArray(); for (const auto &v : items) { const QJsonObject pl = v.toObject(); @@ -80,7 +97,55 @@ void Library::onUserPlaylistsLoaded(const QJsonObject &result) item->setData(0, TypeRole, NodePlaylist); item->setData(0, IdRole, id); item->setData(0, NameRole, name); + + playlists.append({id, name}); } + + emit userPlaylistsChanged(playlists); +} + +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(); + + menu.addSeparator(); + auto *delPl = menu.addAction(tr("Delete \"%1\"…").arg(plName)); + connect(delPl, &QAction::triggered, this, [this, plId, plName] { + const auto answer = QMessageBox::question( + this, + tr("Delete Playlist"), + tr("Permanently delete \"%1\"? This cannot be undone.").arg(plName), + QMessageBox::Yes | QMessageBox::Cancel, + QMessageBox::Cancel); + if (answer == QMessageBox::Yes) + m_backend->deletePlaylist(plId); + }); + } + + menu.exec(viewport()->mapToGlobal(pos)); } void Library::onItemClicked(QTreeWidgetItem *item, int) @@ -95,6 +160,7 @@ void Library::onItemClicked(QTreeWidgetItem *item, int) case NodePlaylist: { const qint64 id = item->data(0, IdRole).toLongLong(); const QString name = item->data(0, NameRole).toString(); + m_openPlaylistId = id; emit playlistRequested(id, name); break; } diff --git a/src/list/library.hpp b/src/list/library.hpp index fb6be76..4bcdf67 100644 --- a/src/list/library.hpp +++ b/src/list/library.hpp @@ -3,6 +3,9 @@ #include "../backend/qobuzbackend.hpp" #include +#include +#include +#include namespace List { @@ -23,17 +26,23 @@ namespace List void favAlbumsRequested(); void favArtistsRequested(); void playlistRequested(qint64 playlistId, const QString &name); + /// Emitted after playlists are loaded so others can cache the list. + void userPlaylistsChanged(const QVector> &playlists); + /// Emitted when the currently open playlist was deleted. + void openPlaylistDeleted(); private slots: void onUserPlaylistsLoaded(const QJsonObject &result); void onItemClicked(QTreeWidgetItem *item, int column); void onItemDoubleClicked(QTreeWidgetItem *item, int column); + void onContextMenuRequested(const QPoint &pos); private: QobuzBackend *m_backend = nullptr; QTreeWidgetItem *m_myLibNode = nullptr; QTreeWidgetItem *m_playlistsNode = nullptr; + qint64 m_openPlaylistId = 0; void buildStaticNodes(); }; diff --git a/src/list/tracks.cpp b/src/list/tracks.cpp index 5a8ffa6..75caa61 100644 --- a/src/list/tracks.cpp +++ b/src/list/tracks.cpp @@ -39,26 +39,41 @@ Tracks::Tracks(QobuzBackend *backend, PlayQueue *queue, QWidget *parent) void Tracks::loadTracks(const QJsonArray &tracks) { + setPlaylistContext(0); m_model->setTracks(tracks, false, /*useSequential=*/true); } void Tracks::loadAlbum(const QJsonObject &album) { + setPlaylistContext(0); const QJsonArray items = album["tracks"].toObject()["items"].toArray(); m_model->setTracks(items); // album: use track_number } void Tracks::loadPlaylist(const QJsonObject &playlist) { + const qint64 id = static_cast(playlist["id"].toDouble()); + setPlaylistContext(id); const QJsonArray items = playlist["tracks"].toObject()["items"].toArray(); m_model->setTracks(items, /*usePosition=*/true); } void Tracks::loadSearchTracks(const QJsonArray &tracks) { + setPlaylistContext(0); m_model->setTracks(tracks, false, /*useSequential=*/true); } +void Tracks::setPlaylistContext(qint64 playlistId) +{ + m_playlistId = playlistId; +} + +void Tracks::setUserPlaylists(const QVector> &playlists) +{ + m_userPlaylists = playlists; +} + void Tracks::setPlayingTrackId(qint64 id) { m_model->setPlayingId(id); @@ -134,6 +149,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) { + 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)); } diff --git a/src/list/tracks.hpp b/src/list/tracks.hpp index 4fa3755..3f57c44 100644 --- a/src/list/tracks.hpp +++ b/src/list/tracks.hpp @@ -7,6 +7,9 @@ #include #include #include +#include +#include +#include namespace List { @@ -25,13 +28,22 @@ namespace List /// Called when the backend fires EV_TRACK_CHANGED so the playing row is highlighted. void setPlayingTrackId(qint64 id); + /// Set which playlist is currently displayed (0 = none). + void setPlaylistContext(qint64 playlistId); + /// Provide the user's playlist list for the "Add to playlist" submenu. + void setUserPlaylists(const QVector> &playlists); + signals: void playTrackRequested(qint64 trackId); + void addToPlaylistRequested(qint64 trackId, qint64 playlistId); + void removeFromPlaylistRequested(qint64 playlistId, qint64 playlistTrackId); private: TrackListModel *m_model = nullptr; QobuzBackend *m_backend = nullptr; PlayQueue *m_queue = nullptr; + qint64 m_playlistId = 0; + QVector> m_userPlaylists; void onDoubleClicked(const QModelIndex &index); void onContextMenu(const QPoint &pos); diff --git a/src/main.cpp b/src/main.cpp index 442c58f..4875489 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -11,27 +11,67 @@ int main(int argc, char *argv[]) app.setOrganizationName(QStringLiteral("qobuz-qt")); app.setApplicationVersion(QStringLiteral("0.1.0")); - // Dark palette consistent with spotify-qt style + // Qobuz dark palette + // Accent: #FFB232 (yellow-orange), Blue: #46B3EE, Backgrounds: #191919 / #141414 app.setStyle(QStyleFactory::create(QStringLiteral("Fusion"))); QPalette darkPalette; - darkPalette.setColor(QPalette::Window, QColor(35, 35, 35)); - darkPalette.setColor(QPalette::WindowText, Qt::white); - darkPalette.setColor(QPalette::Base, QColor(25, 25, 25)); - darkPalette.setColor(QPalette::AlternateBase, QColor(45, 45, 45)); - darkPalette.setColor(QPalette::ToolTipBase, Qt::white); - darkPalette.setColor(QPalette::ToolTipText, Qt::white); - darkPalette.setColor(QPalette::Text, Qt::white); - darkPalette.setColor(QPalette::Button, QColor(53, 53, 53)); - darkPalette.setColor(QPalette::ButtonText, Qt::white); - darkPalette.setColor(QPalette::BrightText, Qt::red); - darkPalette.setColor(QPalette::Link, QColor(42, 130, 218)); - darkPalette.setColor(QPalette::Highlight, QColor(42, 130, 218)); - darkPalette.setColor(QPalette::HighlightedText, Qt::black); - darkPalette.setColor(QPalette::PlaceholderText, QColor(140, 140, 140)); - darkPalette.setColor(QPalette::Disabled, QPalette::Text, QColor(127, 127, 127)); - darkPalette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor(127, 127, 127)); + darkPalette.setColor(QPalette::Window, QColor(0x19, 0x19, 0x19)); + darkPalette.setColor(QPalette::WindowText, QColor(0xe8, 0xe8, 0xe8)); + darkPalette.setColor(QPalette::Base, QColor(0x14, 0x14, 0x14)); + darkPalette.setColor(QPalette::AlternateBase, QColor(0x1e, 0x1e, 0x1e)); + darkPalette.setColor(QPalette::ToolTipBase, QColor(0x19, 0x19, 0x19)); + darkPalette.setColor(QPalette::ToolTipText, QColor(0xe8, 0xe8, 0xe8)); + darkPalette.setColor(QPalette::Text, QColor(0xe8, 0xe8, 0xe8)); + darkPalette.setColor(QPalette::Button, QColor(0x2a, 0x2a, 0x2a)); + darkPalette.setColor(QPalette::ButtonText, QColor(0xe8, 0xe8, 0xe8)); + darkPalette.setColor(QPalette::BrightText, QColor(0xFF, 0xB2, 0x32)); + darkPalette.setColor(QPalette::Link, QColor(0x46, 0xB3, 0xEE)); // Qobuz blue + darkPalette.setColor(QPalette::Highlight, QColor(0xFF, 0xB2, 0x32)); // Qobuz orange + darkPalette.setColor(QPalette::HighlightedText, QColor(0x10, 0x10, 0x10)); // dark on orange + darkPalette.setColor(QPalette::PlaceholderText, QColor(0x66, 0x66, 0x66)); + darkPalette.setColor(QPalette::Disabled, QPalette::Text, QColor(0x55, 0x55, 0x55)); + darkPalette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor(0x55, 0x55, 0x55)); + darkPalette.setColor(QPalette::Mid, QColor(0x2f, 0x2f, 0x2f)); + darkPalette.setColor(QPalette::Dark, QColor(0x0e, 0x0e, 0x0e)); app.setPalette(darkPalette); + // Stylesheet tweaks: orange accent on scrollbars, focus rings, etc. + app.setStyleSheet(QStringLiteral( + "QScrollBar:vertical { width: 6px; background: #141414; border: none; }" + "QScrollBar::handle:vertical { background: #3a3a3a; border-radius: 3px; min-height: 20px; }" + "QScrollBar::handle:vertical:hover { background: #FFB232; }" + "QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }" + "QScrollBar:horizontal { height: 6px; background: #141414; border: none; }" + "QScrollBar::handle:horizontal { background: #3a3a3a; border-radius: 3px; min-width: 20px; }" + "QScrollBar::handle:horizontal:hover { background: #FFB232; }" + "QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }" + "QToolBar { background: #111111; border-bottom: 1px solid #2a2a2a; spacing: 4px; }" + "QDockWidget { border: none; }" + "QDockWidget::title { background: #1e1e1e; padding: 4px 8px; font-weight: bold; }" + "QTreeView, QTreeWidget { border: none; outline: none; }" + "QTreeView::item:selected, QTreeWidget::item:selected { color: #101010; }" + "QHeaderView::section { background: #1e1e1e; border: none;" + " border-right: 1px solid #2a2a2a; padding: 4px 8px; }" + "QMenu { background: #1e1e1e; border: 1px solid #3a3a3a; }" + "QMenu::item:selected { background: #FFB232; color: #101010; }" + "QPushButton { background: #2a2a2a; border: 1px solid #3a3a3a; border-radius: 4px; padding: 4px 12px; }" + "QPushButton:hover { background: #333333; border-color: #FFB232; }" + "QPushButton:pressed { background: #FFB232; color: #101010; }" + "QComboBox { background: #2a2a2a; border: 1px solid #3a3a3a; border-radius: 4px; padding: 3px 8px; }" + "QComboBox:hover { border-color: #FFB232; }" + "QComboBox QAbstractItemView { background: #1e1e1e; selection-background-color: #FFB232; selection-color: #101010; }" + "QLineEdit { background: #1e1e1e; border: 1px solid #3a3a3a; border-radius: 4px; padding: 4px 8px; }" + "QLineEdit:focus { border-color: #FFB232; }" + "QGroupBox { border: 1px solid #2f2f2f; border-radius: 6px; margin-top: 8px; padding-top: 4px; }" + "QGroupBox::title { subcontrol-origin: margin; left: 8px; color: #FFB232; }" + "QCheckBox::indicator:checked { background: #FFB232; border: 1px solid #FFB232; border-radius: 2px; }" + "QSlider::groove:horizontal { height: 4px; background: #2a2a2a; border-radius: 2px; }" + "QSlider::handle:horizontal { background: #FFB232; width: 12px; height: 12px;" + " margin: -4px 0; border-radius: 6px; }" + "QSlider::sub-page:horizontal { background: #FFB232; border-radius: 2px; }" + "QStatusBar { background: #111111; border-top: 1px solid #2a2a2a; }" + )); + auto *backend = new QobuzBackend; MainWindow window(backend); window.show(); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 864d691..7ea8133 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -77,12 +77,25 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent) connect(m_backend, &QobuzBackend::favArtistsLoaded, this, &MainWindow::onFavArtistsLoaded); connect(m_backend, &QobuzBackend::albumLoaded, this, &MainWindow::onAlbumLoaded); connect(m_backend, &QobuzBackend::artistLoaded, this, &MainWindow::onArtistLoaded); - connect(m_backend, &QobuzBackend::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 &) { + statusBar()->showMessage(tr("Playlist deleted"), 3000); + }); connect(m_backend, &QobuzBackend::trackChanged, this, &MainWindow::onTrackChanged); connect(m_backend, &QobuzBackend::error, this, [this](const QString &msg) { statusBar()->showMessage(tr("Error: %1").arg(msg), 6000); }); + // ---- Library signals ---- + connect(m_library, &List::Library::userPlaylistsChanged, + this, &MainWindow::onUserPlaylistsChanged); + connect(m_library, &List::Library::openPlaylistDeleted, + this, [this] { + m_content->showWelcome(); + statusBar()->showMessage(tr("Playlist deleted"), 3000); + }); + // ---- Library → backend ---- connect(m_library, &List::Library::favTracksRequested, this, [this] { m_backend->getFavTracks(); @@ -102,9 +115,19 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent) statusBar()->showMessage(tr("Loading playlist: %1…").arg(name)); }); - // ---- Track list → playback ---- + // ---- Track list → playback / playlist management ---- connect(m_content->tracksList(), &List::Tracks::playTrackRequested, this, &MainWindow::onPlayTrackRequested); + connect(m_content->tracksList(), &List::Tracks::addToPlaylistRequested, + this, [this](qint64 trackId, qint64 playlistId) { + m_backend->addTrackToPlaylist(playlistId, trackId); + statusBar()->showMessage(tr("Adding track to playlist…"), 3000); + }); + connect(m_content->tracksList(), &List::Tracks::removeFromPlaylistRequested, + this, [this](qint64 playlistId, qint64 playlistTrackId) { + m_backend->deleteTrackFromPlaylist(playlistId, playlistTrackId); + statusBar()->showMessage(tr("Removing track from playlist…"), 3000); + }); // ---- Search panel ---- connect(m_sidePanel, &SidePanel::View::albumSelected, @@ -129,6 +152,9 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent) connect(m_toolBar, &MainToolBar::queueToggled, this, [this](bool v) { m_queuePanel->setVisible(v); }); + // Apply playback options from saved settings + m_backend->setReplayGain(AppSettings::instance().replayGainEnabled()); + tryRestoreSession(); } @@ -233,6 +259,16 @@ void MainWindow::onTrackChanged(const QJsonObject &track) : track["performer"].toObject()["name"].toString(); statusBar()->showMessage( artist.isEmpty() ? title : QStringLiteral("▶ %1 — %2").arg(artist, title)); + + // Gapless: prefetch next track URL so it starts immediately + if (AppSettings::instance().gaplessEnabled() && m_queue->canGoNext()) { + const auto upcoming = m_queue->upcomingTracks(1); + if (!upcoming.isEmpty()) { + const qint64 nextId = static_cast(upcoming.first()["id"].toDouble()); + if (nextId > 0) + m_backend->prefetchTrack(nextId, AppSettings::instance().preferredFormat()); + } + } } void MainWindow::onFavTracksLoaded(const QJsonObject &result) @@ -298,3 +334,15 @@ void MainWindow::onSearchToggled(bool visible) { m_sidePanel->setVisible(visible); } + +void MainWindow::onPlaylistCreated(const QJsonObject &playlist) +{ + statusBar()->showMessage( + tr("Playlist '%1' created").arg(playlist["name"].toString()), 4000); +} + +void MainWindow::onUserPlaylistsChanged(const QVector> &playlists) +{ + m_userPlaylists = playlists; + m_content->tracksList()->setUserPlaylists(playlists); +} diff --git a/src/mainwindow.hpp b/src/mainwindow.hpp index f7dd507..d5a0e02 100644 --- a/src/mainwindow.hpp +++ b/src/mainwindow.hpp @@ -13,6 +13,9 @@ #include #include #include +#include +#include +#include class MainWindow : public QMainWindow { @@ -38,6 +41,8 @@ private slots: void onSearchAlbumSelected(const QString &albumId); void onSearchArtistSelected(qint64 artistId); void onSearchToggled(bool visible); + void onPlaylistCreated(const QJsonObject &playlist); + void onUserPlaylistsChanged(const QVector> &playlists); void showLoginDialog(); void showSettingsDialog(); @@ -45,6 +50,7 @@ private slots: private: QobuzBackend *m_backend = nullptr; PlayQueue *m_queue = nullptr; + QVector> m_userPlaylists; MainToolBar *m_toolBar = nullptr; MainContent *m_content = nullptr; List::Library *m_library = nullptr; diff --git a/src/model/tracklistmodel.cpp b/src/model/tracklistmodel.cpp index 078c987..913cd81 100644 --- a/src/model/tracklistmodel.cpp +++ b/src/model/tracklistmodel.cpp @@ -21,9 +21,10 @@ void TrackListModel::setTracks(const QJsonArray &tracks, for (const QJsonValue &v : tracks) { const QJsonObject t = v.toObject(); TrackItem item; - item.id = static_cast(t["id"].toDouble()); - item.title = t["title"].toString(); - item.duration = static_cast(t["duration"].toDouble()); + item.id = static_cast(t["id"].toDouble()); + item.playlistTrackId = static_cast(t["playlist_track_id"].toDouble()); + item.title = t["title"].toString(); + item.duration = static_cast(t["duration"].toDouble()); item.hiRes = t["hires_streamable"].toBool(); item.streamable = t["streamable"].toBool(true); item.raw = t; @@ -68,6 +69,14 @@ void TrackListModel::clear() endResetModel(); } +void TrackListModel::removeTrack(int row) +{ + if (row < 0 || row >= m_tracks.size()) return; + beginRemoveRows({}, row, row); + m_tracks.removeAt(row); + endRemoveRows(); +} + void TrackListModel::setPlayingId(qint64 id) { m_playingId = id; @@ -111,17 +120,18 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const } if (role == Qt::ForegroundRole) { - if (!t.streamable) return QColor(Qt::gray); - if (isPlaying) return QColor(0x1d, 0xb9, 0x54); // Qobuz green + if (!t.streamable) return QColor(0x55, 0x55, 0x55); + if (isPlaying) return QColor(0xFF, 0xB2, 0x32); // Qobuz orange } if (role == Qt::DecorationRole && index.column() == ColNumber && isPlaying) { return QIcon(QStringLiteral(":/res/icons/media-track-show-active.svg")); } - if (role == TrackIdRole) return t.id; - if (role == TrackJsonRole) return t.raw; - if (role == HiResRole) return t.hiRes; + if (role == TrackIdRole) return t.id; + if (role == TrackJsonRole) return t.raw; + if (role == HiResRole) return t.hiRes; + if (role == PlaylistTrackIdRole) return t.playlistTrackId; return {}; } diff --git a/src/model/tracklistmodel.hpp b/src/model/tracklistmodel.hpp index 72f428c..21b0003 100644 --- a/src/model/tracklistmodel.hpp +++ b/src/model/tracklistmodel.hpp @@ -7,15 +7,16 @@ #include struct TrackItem { - qint64 id = 0; - int number = 0; + qint64 id = 0; + qint64 playlistTrackId = 0; + int number = 0; QString title; QString artist; QString album; QString albumId; - qint64 duration = 0; // seconds - bool hiRes = false; - bool streamable = false; + qint64 duration = 0; // seconds + bool hiRes = false; + bool streamable = false; QJsonObject raw; }; @@ -34,9 +35,10 @@ public: }; enum Role { - TrackIdRole = Qt::UserRole + 1, - TrackJsonRole = Qt::UserRole + 2, - HiResRole = Qt::UserRole + 3, + TrackIdRole = Qt::UserRole + 1, + TrackJsonRole = Qt::UserRole + 2, + HiResRole = Qt::UserRole + 3, + PlaylistTrackIdRole = Qt::UserRole + 4, }; explicit TrackListModel(QObject *parent = nullptr); @@ -50,6 +52,9 @@ public: void setPlayingId(qint64 id); 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); } // Returns the current (possibly sorted) raw JSON objects in display order. diff --git a/src/util/settings.hpp b/src/util/settings.hpp index d08a339..c00fb36 100644 --- a/src/util/settings.hpp +++ b/src/util/settings.hpp @@ -31,6 +31,13 @@ public: bool rememberLogin() const { return m_settings.value("auth/remember", true).toBool(); } void setRememberLogin(bool r) { m_settings.setValue("auth/remember", r); } + // --- Playback extras --- + bool replayGainEnabled() const { return m_settings.value("playback/replaygain", false).toBool(); } + void setReplayGainEnabled(bool v) { m_settings.setValue("playback/replaygain", v); } + + bool gaplessEnabled() const { return m_settings.value("playback/gapless", false).toBool(); } + void setGaplessEnabled(bool v) { m_settings.setValue("playback/gapless", v); } + // --- Last.fm --- bool lastFmEnabled() const { return m_settings.value("lastfm/enabled", false).toBool(); } void setLastFmEnabled(bool v) { m_settings.setValue("lastfm/enabled", v); } diff --git a/src/view/albumlistview.hpp b/src/view/albumlistview.hpp index b558a87..a0ebb86 100644 --- a/src/view/albumlistview.hpp +++ b/src/view/albumlistview.hpp @@ -58,7 +58,7 @@ public: auto *item = new QTreeWidgetItem(this); if (hiRes) { item->setText(0, QStringLiteral("H")); - item->setForeground(0, QColor(QStringLiteral("#FFD700"))); + item->setForeground(0, QColor(QStringLiteral("#FFB232"))); item->setFont(0, hiResFont); item->setTextAlignment(0, Qt::AlignCenter); } diff --git a/src/view/queuepanel.cpp b/src/view/queuepanel.cpp index 20951d9..9e230ef 100644 --- a/src/view/queuepanel.cpp +++ b/src/view/queuepanel.cpp @@ -23,7 +23,7 @@ public: QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &) const override { - return QSize(0, QFontMetrics(option.font).height() + 10); + return QSize(0, QFontMetrics(option.font).height() + 4); } void paint(QPainter *p, const QStyleOptionViewItem &option, diff --git a/src/view/sidepanel/view.cpp b/src/view/sidepanel/view.cpp index 8792212..76c5325 100644 --- a/src/view/sidepanel/view.cpp +++ b/src/view/sidepanel/view.cpp @@ -105,7 +105,7 @@ void SearchTab::onSearchResult(const QJsonObject &result) QStringList{QString(), a["title"].toString(), artist}); if (hiRes) { item->setText(0, QStringLiteral("H")); - item->setForeground(0, QColor(QStringLiteral("#FFD700"))); + item->setForeground(0, QColor(QStringLiteral("#FFB232"))); item->setFont(0, hiResFont); item->setTextAlignment(0, Qt::AlignCenter); } From 75e2b623b80c8857a519907fa9bbeadf44a703ba Mon Sep 17 00:00:00 2001 From: joren Date: Tue, 24 Mar 2026 11:23:45 +0100 Subject: [PATCH 02/10] feat: refresh views on all playlist mutations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- rust/include/qobuz_backend.h | 5 +++-- rust/src/lib.rs | 7 +++++-- src/backend/qobuzbackend.cpp | 3 +++ src/backend/qobuzbackend.hpp | 1 + src/list/tracks.hpp | 1 + src/mainwindow.cpp | 16 +++++++++++++--- 6 files changed, 26 insertions(+), 7 deletions(-) diff --git a/rust/include/qobuz_backend.h b/rust/include/qobuz_backend.h index ecf32c5..01b602b 100644 --- a/rust/include/qobuz_backend.h +++ b/rust/include/qobuz_backend.h @@ -31,8 +31,9 @@ enum QobuzEvent { EV_TRACK_URL_OK = 17, EV_TRACK_URL_ERR = 18, EV_GENERIC_ERR = 19, - EV_PLAYLIST_CREATED = 20, - EV_PLAYLIST_DELETED = 21, + EV_PLAYLIST_CREATED = 20, + EV_PLAYLIST_DELETED = 21, + EV_PLAYLIST_TRACK_ADDED = 22, }; // Callback signature diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 09601fd..1ab104a 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -562,6 +562,7 @@ pub unsafe extern "C" fn qobuz_backend_remove_fav_album(ptr: *mut Backend, album 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) { @@ -601,8 +602,10 @@ pub unsafe extern "C" fn qobuz_backend_add_track_to_playlist( let client = inner.client.clone(); let cb = inner.cb; let ud = inner.ud; spawn(inner, async move { - if let Err(e) = client.lock().await.add_track_to_playlist(playlist_id, track_id).await { - call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())); + 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())), } }); } diff --git a/src/backend/qobuzbackend.cpp b/src/backend/qobuzbackend.cpp index 16fbb7f..2ca0eea 100644 --- a/src/backend/qobuzbackend.cpp +++ b/src/backend/qobuzbackend.cpp @@ -241,6 +241,9 @@ void QobuzBackend::onEvent(int eventType, const QString &json) case 21: // EV_PLAYLIST_DELETED emit playlistDeleted(obj); break; + case 22: // EV_PLAYLIST_TRACK_ADDED + emit playlistTrackAdded(static_cast(obj["playlist_id"].toDouble())); + break; case EV_GENERIC_ERR: case EV_TRACK_URL_ERR: emit error(obj["error"].toString()); diff --git a/src/backend/qobuzbackend.hpp b/src/backend/qobuzbackend.hpp index 48ef4b0..d76da97 100644 --- a/src/backend/qobuzbackend.hpp +++ b/src/backend/qobuzbackend.hpp @@ -78,6 +78,7 @@ signals: void playlistLoaded(const QJsonObject &playlist); void playlistCreated(const QJsonObject &playlist); void playlistDeleted(const QJsonObject &result); + void playlistTrackAdded(qint64 playlistId); // favorites void favTracksLoaded(const QJsonObject &result); diff --git a/src/list/tracks.hpp b/src/list/tracks.hpp index 3f57c44..2b7b3b7 100644 --- a/src/list/tracks.hpp +++ b/src/list/tracks.hpp @@ -30,6 +30,7 @@ namespace List /// Set which playlist is currently displayed (0 = none). void setPlaylistContext(qint64 playlistId); + qint64 playlistId() const { return m_playlistId; } /// Provide the user's playlist list for the "Add to playlist" submenu. void setUserPlaylists(const QVector> &playlists); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 7ea8133..8ecab59 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -80,7 +80,13 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent) 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 &) { - statusBar()->showMessage(tr("Playlist deleted"), 3000); + // status bar message is also shown by library's openPlaylistDeleted handler + }); + connect(m_backend, &QobuzBackend::playlistTrackAdded, this, [this](qint64 playlistId) { + // Refresh the currently shown playlist if a track was added to it + if (m_content->tracksList()->playlistId() == playlistId) + m_backend->getPlaylist(playlistId); + statusBar()->showMessage(tr("Track added to playlist"), 3000); }); connect(m_backend, &QobuzBackend::trackChanged, this, &MainWindow::onTrackChanged); connect(m_backend, &QobuzBackend::error, this, [this](const QString &msg) { @@ -337,8 +343,12 @@ void MainWindow::onSearchToggled(bool visible) void MainWindow::onPlaylistCreated(const QJsonObject &playlist) { - statusBar()->showMessage( - tr("Playlist '%1' created").arg(playlist["name"].toString()), 4000); + 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(playlist["id"].toDouble()); + if (id > 0) + m_backend->getPlaylist(id); } void MainWindow::onUserPlaylistsChanged(const QVector> &playlists) From 8088412d4b9fdaf4f114a1ed9b7dc3f9bb4cc24e Mon Sep 17 00:00:00 2001 From: joren Date: Tue, 24 Mar 2026 11:34:04 +0100 Subject: [PATCH 03/10] feat: album/playlist header and playlist ownership filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/list/library.cpp | 56 ++++++----- src/list/library.hpp | 1 + src/list/tracks.cpp | 13 ++- src/list/tracks.hpp | 4 +- src/mainwindow.cpp | 3 + src/util/settings.hpp | 3 + src/view/maincontent.cpp | 19 +++- src/view/maincontent.hpp | 16 ++-- src/view/trackcontextheader.hpp | 162 ++++++++++++++++++++++++++++++++ 9 files changed, 240 insertions(+), 37 deletions(-) create mode 100644 src/view/trackcontextheader.hpp diff --git a/src/list/library.cpp b/src/list/library.cpp index 288779b..fdbe433 100644 --- a/src/list/library.cpp +++ b/src/list/library.cpp @@ -13,6 +13,7 @@ namespace List static constexpr int TypeRole = Qt::UserRole + 1; static constexpr int IdRole = Qt::UserRole + 2; static constexpr int NameRole = Qt::UserRole + 3; +static constexpr int IsOwnerRole = Qt::UserRole + 4; enum NodeType { NodeFavTracks, @@ -86,22 +87,28 @@ void Library::onUserPlaylistsLoaded(const QJsonObject &result) while (m_playlistsNode->childCount() > 0) delete m_playlistsNode->takeChild(0); - QVector> playlists; + QVector> editablePlaylists; + const qint64 myUserId = AppSettings::instance().userId(); const QJsonArray items = result["items"].toArray(); for (const auto &v : items) { - const QJsonObject pl = v.toObject(); - const QString name = pl["name"].toString(); - const qint64 id = static_cast(pl["id"].toDouble()); + const QJsonObject pl = v.toObject(); + const QString name = pl["name"].toString(); + const qint64 id = static_cast(pl["id"].toDouble()); + const qint64 ownId = static_cast(pl["owner"].toObject()["id"].toDouble()); + const bool isOwner = (myUserId > 0 && ownId == myUserId); auto *item = new QTreeWidgetItem(m_playlistsNode, QStringList{name}); - item->setData(0, TypeRole, NodePlaylist); - item->setData(0, IdRole, id); - item->setData(0, NameRole, name); + item->setData(0, TypeRole, NodePlaylist); + item->setData(0, IdRole, id); + item->setData(0, NameRole, name); + item->setData(0, IsOwnerRole, isOwner); - playlists.append({id, name}); + // Only include playlists we can edit in the "Add to playlist" submenu + if (isOwner) + editablePlaylists.append({id, name}); } - emit userPlaylistsChanged(playlists); + emit userPlaylistsChanged(editablePlaylists); } void Library::onContextMenuRequested(const QPoint &pos) @@ -128,21 +135,24 @@ void Library::onContextMenuRequested(const QPoint &pos) }); if (isPlaylistItem) { - const qint64 plId = item->data(0, IdRole).toLongLong(); - const QString plName = item->data(0, NameRole).toString(); + const qint64 plId = item->data(0, IdRole).toLongLong(); + const QString plName = item->data(0, NameRole).toString(); + const bool isOwner = item->data(0, IsOwnerRole).toBool(); - 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); - }); + 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)); diff --git a/src/list/library.hpp b/src/list/library.hpp index 4bcdf67..529aa2c 100644 --- a/src/list/library.hpp +++ b/src/list/library.hpp @@ -1,6 +1,7 @@ #pragma once #include "../backend/qobuzbackend.hpp" +#include "../util/settings.hpp" #include #include diff --git a/src/list/tracks.cpp b/src/list/tracks.cpp index 75caa61..27e9926 100644 --- a/src/list/tracks.cpp +++ b/src/list/tracks.cpp @@ -1,4 +1,5 @@ #include "tracks.hpp" +#include "../util/settings.hpp" #include #include @@ -52,8 +53,11 @@ void Tracks::loadAlbum(const QJsonObject &album) void Tracks::loadPlaylist(const QJsonObject &playlist) { - const qint64 id = static_cast(playlist["id"].toDouble()); - setPlaylistContext(id); + const qint64 id = static_cast(playlist["id"].toDouble()); + const qint64 ownId = static_cast(playlist["owner"].toObject()["id"].toDouble()); + const qint64 myId = AppSettings::instance().userId(); + const bool isOwned = (myId > 0 && ownId == myId); + setPlaylistContext(id, isOwned); const QJsonArray items = playlist["tracks"].toObject()["items"].toArray(); m_model->setTracks(items, /*usePosition=*/true); } @@ -64,9 +68,10 @@ void Tracks::loadSearchTracks(const QJsonArray &tracks) m_model->setTracks(tracks, false, /*useSequential=*/true); } -void Tracks::setPlaylistContext(qint64 playlistId) +void Tracks::setPlaylistContext(qint64 playlistId, bool isOwned) { m_playlistId = playlistId; + m_playlistIsOwned = isOwned; } void Tracks::setUserPlaylists(const QVector> &playlists) @@ -164,7 +169,7 @@ void Tracks::onContextMenu(const QPoint &pos) } } - if (m_playlistId > 0) { + if (m_playlistId > 0 && m_playlistIsOwned) { const qint64 playlistTrackId = m_model->data(index, TrackListModel::PlaylistTrackIdRole).toLongLong(); if (playlistTrackId > 0) { diff --git a/src/list/tracks.hpp b/src/list/tracks.hpp index 2b7b3b7..5df2f9a 100644 --- a/src/list/tracks.hpp +++ b/src/list/tracks.hpp @@ -29,7 +29,8 @@ namespace List void setPlayingTrackId(qint64 id); /// Set which playlist is currently displayed (0 = none). - void setPlaylistContext(qint64 playlistId); + /// 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> &playlists); @@ -44,6 +45,7 @@ namespace List QobuzBackend *m_backend = nullptr; PlayQueue *m_queue = nullptr; qint64 m_playlistId = 0; + bool m_playlistIsOwned = false; QVector> m_userPlaylists; void onDoubleClicked(const QModelIndex &index); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 8ecab59..6683484 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -242,6 +242,9 @@ void MainWindow::onLoginSuccess(const QString &token, const QJsonObject &user) const QString email = user["email"].toString(); AppSettings::instance().setDisplayName(displayName); AppSettings::instance().setUserEmail(email); + const qint64 userId = static_cast(user["id"].toDouble()); + if (userId > 0) + AppSettings::instance().setUserId(userId); statusBar()->showMessage(tr("Signed in as %1").arg( displayName.isEmpty() ? email : displayName)); m_library->refresh(); diff --git a/src/util/settings.hpp b/src/util/settings.hpp index c00fb36..e637ac6 100644 --- a/src/util/settings.hpp +++ b/src/util/settings.hpp @@ -21,6 +21,9 @@ public: QString displayName() const { return m_settings.value("user/display_name").toString(); } void setDisplayName(const QString &n) { m_settings.setValue("user/display_name", n); } + qint64 userId() const { return m_settings.value("user/id", 0LL).toLongLong(); } + void setUserId(qint64 id) { m_settings.setValue("user/id", id); } + // 5 = MP3, 6 = CD, 7 = HiRes96, 27 = HiRes192 int preferredFormat() const { return m_settings.value("playback/format", 6).toInt(); } void setPreferredFormat(int f) { m_settings.setValue("playback/format", f); } diff --git a/src/view/maincontent.cpp b/src/view/maincontent.cpp index 765d45a..d26170f 100644 --- a/src/view/maincontent.cpp +++ b/src/view/maincontent.cpp @@ -19,13 +19,24 @@ MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *paren this); m_welcome->setAlignment(Qt::AlignCenter); - m_tracks = new List::Tracks(m_backend, queue, this); + // Tracks page: context header + track list + auto *tracksPage = new QWidget(this); + auto *tracksLayout = new QVBoxLayout(tracksPage); + tracksLayout->setContentsMargins(0, 0, 0, 0); + tracksLayout->setSpacing(0); + + m_header = new TrackContextHeader(tracksPage); + m_header->hide(); + m_tracks = new List::Tracks(m_backend, queue, tracksPage); + tracksLayout->addWidget(m_header); + tracksLayout->addWidget(m_tracks, 1); + m_albumList = new AlbumListView(this); m_artistList = new ArtistListView(this); m_artistView = new ArtistView(this); m_stack->addWidget(m_welcome); // 0 - m_stack->addWidget(m_tracks); // 1 + m_stack->addWidget(tracksPage); // 1 m_stack->addWidget(m_albumList); // 2 m_stack->addWidget(m_artistList); // 3 m_stack->addWidget(m_artistView); // 4 @@ -41,24 +52,28 @@ void MainContent::showWelcome() { m_stack->setCurrentIndex(0); } void MainContent::showAlbum(const QJsonObject &album) { + m_header->setAlbum(album); m_tracks->loadAlbum(album); m_stack->setCurrentIndex(1); } void MainContent::showPlaylist(const QJsonObject &playlist) { + m_header->setPlaylist(playlist); m_tracks->loadPlaylist(playlist); m_stack->setCurrentIndex(1); } void MainContent::showFavTracks(const QJsonObject &result) { + m_header->hide(); m_tracks->loadTracks(result["items"].toArray()); m_stack->setCurrentIndex(1); } void MainContent::showSearchTracks(const QJsonArray &tracks) { + m_header->hide(); m_tracks->loadSearchTracks(tracks); m_stack->setCurrentIndex(1); } diff --git a/src/view/maincontent.hpp b/src/view/maincontent.hpp index 487c045..b3d81c2 100644 --- a/src/view/maincontent.hpp +++ b/src/view/maincontent.hpp @@ -6,6 +6,7 @@ #include "albumlistview.hpp" #include "artistlistview.hpp" #include "artistview.hpp" +#include "trackcontextheader.hpp" #include #include @@ -36,11 +37,12 @@ signals: void artistRequested(qint64 artistId); private: - QobuzBackend *m_backend = nullptr; - QStackedWidget *m_stack = nullptr; - QLabel *m_welcome = nullptr; - List::Tracks *m_tracks = nullptr; - AlbumListView *m_albumList = nullptr; - ArtistListView *m_artistList = nullptr; - ArtistView *m_artistView = nullptr; + QobuzBackend *m_backend = nullptr; + QStackedWidget *m_stack = nullptr; + QLabel *m_welcome = nullptr; + List::Tracks *m_tracks = nullptr; + TrackContextHeader *m_header = nullptr; + AlbumListView *m_albumList = nullptr; + ArtistListView *m_artistList = nullptr; + ArtistView *m_artistView = nullptr; }; diff --git a/src/view/trackcontextheader.hpp b/src/view/trackcontextheader.hpp new file mode 100644 index 0000000..4eb8dcd --- /dev/null +++ b/src/view/trackcontextheader.hpp @@ -0,0 +1,162 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/// Header strip shown above the track list when an album or playlist is open. +/// Displays album art, title, subtitle, and metadata. +class TrackContextHeader : public QWidget +{ +public: + explicit TrackContextHeader(QWidget *parent = nullptr) + : QWidget(parent) + { + setFixedHeight(140); + + 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); + + vlay->addStretch(); + 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); + }); + } + + 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)); + + const QJsonArray imgs = playlist["image_rectangle"].toArray(); + if (!imgs.isEmpty()) + fetchUrl(imgs.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(album["duration"].toDouble()); + if (dur > 0) parts << formatDuration(dur); + 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(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; + QNetworkAccessManager *m_nam = nullptr; + QString m_currentArtUrl; +}; From c035ce2deeb71698b7d266753d11befefee44a77 Mon Sep 17 00:00:00 2001 From: joren Date: Tue, 24 Mar 2026 11:36:54 +0100 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20remove=20gapless=20toggle=20?= =?UTF-8?q?=E2=80=94=20architecture=20is=20always=20gapless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/dialog/settings.cpp | 5 ----- src/dialog/settings.hpp | 1 - src/mainwindow.cpp | 4 ++-- src/util/settings.hpp | 3 --- 4 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/dialog/settings.cpp b/src/dialog/settings.cpp index 135d541..a3fef51 100644 --- a/src/dialog/settings.cpp +++ b/src/dialog/settings.cpp @@ -41,10 +41,6 @@ SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent) m_replayGain->setChecked(AppSettings::instance().replayGainEnabled()); playLayout->addRow(m_replayGain); - m_gapless = new QCheckBox(tr("Gapless playback (pre-fetch next track)"), playGroup); - m_gapless->setChecked(AppSettings::instance().gaplessEnabled()); - playLayout->addRow(m_gapless); - layout->addWidget(playGroup); // --- Last.fm group --- @@ -100,7 +96,6 @@ void SettingsDialog::applyChanges() { AppSettings::instance().setPreferredFormat(m_formatBox->currentData().toInt()); AppSettings::instance().setReplayGainEnabled(m_replayGain->isChecked()); - AppSettings::instance().setGaplessEnabled(m_gapless->isChecked()); AppSettings::instance().setLastFmEnabled(m_lastFmEnabled->isChecked()); AppSettings::instance().setLastFmApiKey(m_lastFmApiKey->text().trimmed()); AppSettings::instance().setLastFmApiSecret(m_lastFmApiSecret->text().trimmed()); diff --git a/src/dialog/settings.hpp b/src/dialog/settings.hpp index 10ff78b..534b4cb 100644 --- a/src/dialog/settings.hpp +++ b/src/dialog/settings.hpp @@ -19,7 +19,6 @@ private: // Playback QComboBox *m_formatBox = nullptr; QCheckBox *m_replayGain = nullptr; - QCheckBox *m_gapless = nullptr; // Last.fm QCheckBox *m_lastFmEnabled = nullptr; diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 6683484..3174f3d 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -269,8 +269,8 @@ void MainWindow::onTrackChanged(const QJsonObject &track) statusBar()->showMessage( artist.isEmpty() ? title : QStringLiteral("▶ %1 — %2").arg(artist, title)); - // Gapless: prefetch next track URL so it starts immediately - if (AppSettings::instance().gaplessEnabled() && m_queue->canGoNext()) { + // Prefetch next track URL to minimise the gap between tracks + if (m_queue->canGoNext()) { const auto upcoming = m_queue->upcomingTracks(1); if (!upcoming.isEmpty()) { const qint64 nextId = static_cast(upcoming.first()["id"].toDouble()); diff --git a/src/util/settings.hpp b/src/util/settings.hpp index e637ac6..49828dc 100644 --- a/src/util/settings.hpp +++ b/src/util/settings.hpp @@ -38,9 +38,6 @@ public: 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); } From 1e4c234b5c8926e65246b719ef9f73e0be3df63d Mon Sep 17 00:00:00 2001 From: joren Date: Tue, 24 Mar 2026 11:39:24 +0100 Subject: [PATCH 05/10] 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 --- rust/include/qobuz_backend.h | 1 + rust/src/lib.rs | 5 +++++ rust/src/player/mod.rs | 8 +++++++- src/backend/qobuzbackend.cpp | 5 +++++ src/backend/qobuzbackend.hpp | 1 + src/dialog/settings.cpp | 5 +++++ src/dialog/settings.hpp | 1 + src/mainwindow.cpp | 5 +++-- src/util/settings.hpp | 3 +++ 9 files changed, 31 insertions(+), 3 deletions(-) diff --git a/rust/include/qobuz_backend.h b/rust/include/qobuz_backend.h index 01b602b..83d66a0 100644 --- a/rust/include/qobuz_backend.h +++ b/rust/include/qobuz_backend.h @@ -74,6 +74,7 @@ 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 diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 1ab104a..e1044b5 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -478,6 +478,11 @@ pub unsafe extern "C" fn qobuz_backend_set_replaygain(ptr: *mut Backend, enabled (*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, diff --git a/rust/src/player/mod.rs b/rust/src/player/mod.rs index b28f69c..872f1b5 100644 --- a/rust/src/player/mod.rs +++ b/rust/src/player/mod.rs @@ -51,6 +51,8 @@ pub struct PlayerStatus { pub seek_target_secs: Arc, /// Linear gain factor to apply (1.0 = unity). Updated each time a new track starts. pub replaygain_gain: Arc>, + /// When false the audio output is torn down after each track, producing a gap. + pub gapless: Arc, } impl PlayerStatus { @@ -65,6 +67,7 @@ impl PlayerStatus { 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)), } } @@ -197,7 +200,10 @@ fn player_loop(rx: std::sync::mpsc::Receiver, status: PlayerStatu pending_info = Some(next_info); } Ok(None) => { - // Track finished naturally + // Track finished naturally — tear down audio output if gapless is off + if !status.gapless.load(Ordering::Relaxed) { + audio_output = None; + } *status.state.lock().unwrap() = PlayerState::Idle; status.track_finished.store(true, Ordering::SeqCst); } diff --git a/src/backend/qobuzbackend.cpp b/src/backend/qobuzbackend.cpp index 2ca0eea..dd5b826 100644 --- a/src/backend/qobuzbackend.cpp +++ b/src/backend/qobuzbackend.cpp @@ -86,6 +86,11 @@ 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); diff --git a/src/backend/qobuzbackend.hpp b/src/backend/qobuzbackend.hpp index d76da97..4ac0a29 100644 --- a/src/backend/qobuzbackend.hpp +++ b/src/backend/qobuzbackend.hpp @@ -38,6 +38,7 @@ public: // --- playback options --- void setReplayGain(bool enabled); + void setGapless(bool enabled); void prefetchTrack(qint64 trackId, int formatId = 6); // --- playlist management --- diff --git a/src/dialog/settings.cpp b/src/dialog/settings.cpp index a3fef51..2b990a7 100644 --- a/src/dialog/settings.cpp +++ b/src/dialog/settings.cpp @@ -41,6 +41,10 @@ SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent) m_replayGain->setChecked(AppSettings::instance().replayGainEnabled()); playLayout->addRow(m_replayGain); + m_gapless = new QCheckBox(tr("Gapless playback"), playGroup); + m_gapless->setChecked(AppSettings::instance().gaplessEnabled()); + playLayout->addRow(m_gapless); + layout->addWidget(playGroup); // --- Last.fm group --- @@ -96,6 +100,7 @@ void SettingsDialog::applyChanges() { AppSettings::instance().setPreferredFormat(m_formatBox->currentData().toInt()); AppSettings::instance().setReplayGainEnabled(m_replayGain->isChecked()); + AppSettings::instance().setGaplessEnabled(m_gapless->isChecked()); AppSettings::instance().setLastFmEnabled(m_lastFmEnabled->isChecked()); AppSettings::instance().setLastFmApiKey(m_lastFmApiKey->text().trimmed()); AppSettings::instance().setLastFmApiSecret(m_lastFmApiSecret->text().trimmed()); diff --git a/src/dialog/settings.hpp b/src/dialog/settings.hpp index 534b4cb..10ff78b 100644 --- a/src/dialog/settings.hpp +++ b/src/dialog/settings.hpp @@ -19,6 +19,7 @@ private: // Playback QComboBox *m_formatBox = nullptr; QCheckBox *m_replayGain = nullptr; + QCheckBox *m_gapless = nullptr; // Last.fm QCheckBox *m_lastFmEnabled = nullptr; diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 3174f3d..0838733 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -160,6 +160,7 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent) // Apply playback options from saved settings m_backend->setReplayGain(AppSettings::instance().replayGainEnabled()); + m_backend->setGapless(AppSettings::instance().gaplessEnabled()); tryRestoreSession(); } @@ -269,8 +270,8 @@ void MainWindow::onTrackChanged(const QJsonObject &track) statusBar()->showMessage( artist.isEmpty() ? title : QStringLiteral("▶ %1 — %2").arg(artist, title)); - // Prefetch next track URL to minimise the gap between tracks - if (m_queue->canGoNext()) { + // 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(upcoming.first()["id"].toDouble()); diff --git a/src/util/settings.hpp b/src/util/settings.hpp index 49828dc..e637ac6 100644 --- a/src/util/settings.hpp +++ b/src/util/settings.hpp @@ -38,6 +38,9 @@ public: 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); } From 7b0e5dcfa83a4847a71d5730e97ec54017af50a0 Mon Sep 17 00:00:00 2001 From: joren Date: Tue, 24 Mar 2026 11:44:35 +0100 Subject: [PATCH 06/10] feat: show resolution in album header (e.g. 24-bit / 96 kHz) Co-Authored-By: Claude Sonnet 4.6 --- src/view/trackcontextheader.hpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/view/trackcontextheader.hpp b/src/view/trackcontextheader.hpp index 4eb8dcd..440854d 100644 --- a/src/view/trackcontextheader.hpp +++ b/src/view/trackcontextheader.hpp @@ -140,6 +140,15 @@ private: if (tracks > 0) parts << QStringLiteral("%1 tracks").arg(tracks); const int dur = static_cast(album["duration"].toDouble()); if (dur > 0) parts << formatDuration(dur); + // Resolution: e.g. "24-bit / 96 kHz" + 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(rate)) + ? QString::number(static_cast(rate)) + : QString::number(rate, 'g', 4); + parts << QStringLiteral("%1-bit / %2 kHz").arg(bits).arg(rateStr); + } return parts.join(QStringLiteral(" · ")); } From 9ca17b44064bbe86c89ad099ac196d051d8b7474 Mon Sep 17 00:00:00 2001 From: joren Date: Tue, 24 Mar 2026 11:46:43 +0100 Subject: [PATCH 07/10] 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 --- src/view/context/view.cpp | 27 +++++++++++++++++++-------- src/view/context/view.hpp | 8 ++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/view/context/view.cpp b/src/view/context/view.cpp index 09204e9..62a2809 100644 --- a/src/view/context/view.cpp +++ b/src/view/context/view.cpp @@ -7,8 +7,6 @@ namespace Context { -static constexpr int ArtSize = 96; - View::View(QobuzBackend *backend, QWidget *parent) : QDockWidget(tr("Now Playing"), parent) , m_backend(backend) @@ -25,12 +23,11 @@ View::View(QobuzBackend *backend, QWidget *parent) layout->setSpacing(6); m_albumArt = new QLabel(container); - m_albumArt->setFixedSize(ArtSize, ArtSize); - m_albumArt->setScaledContents(true); m_albumArt->setAlignment(Qt::AlignCenter); m_albumArt->setStyleSheet(QStringLiteral( "background: #1a1a1a; border-radius: 4px;")); - layout->addWidget(m_albumArt, 0, Qt::AlignCenter); + m_albumArt->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + layout->addWidget(m_albumArt); m_title = new QLabel(tr("Not playing"), container); m_title->setAlignment(Qt::AlignCenter); @@ -80,9 +77,23 @@ void View::onArtReady(QNetworkReply *reply) reply->deleteLater(); if (reply->error() != QNetworkReply::NoError) return; - QPixmap pix; - if (pix.loadFromData(reply->readAll())) - m_albumArt->setPixmap(pix); + if (m_artPixmap.loadFromData(reply->readAll())) + scaleArtToWidth(); +} + +void View::resizeEvent(QResizeEvent *event) +{ + QDockWidget::resizeEvent(event); + scaleArtToWidth(); +} + +void View::scaleArtToWidth() +{ + if (m_artPixmap.isNull()) return; + // Available width = dock width minus the 8px margins on each side + const int side = qMax(32, widget() ? widget()->width() - 16 : width() - 16); + m_albumArt->setFixedSize(side, side); + m_albumArt->setPixmap(m_artPixmap.scaled(side, side, Qt::KeepAspectRatio, Qt::SmoothTransformation)); } } // namespace Context diff --git a/src/view/context/view.hpp b/src/view/context/view.hpp index 3ae00ba..c2012b4 100644 --- a/src/view/context/view.hpp +++ b/src/view/context/view.hpp @@ -4,6 +4,8 @@ #include #include +#include +#include #include #include #include @@ -21,12 +23,18 @@ namespace Context void onTrackChanged(const QJsonObject &track); void onArtReady(QNetworkReply *reply); + protected: + void resizeEvent(QResizeEvent *event) override; + private: + void scaleArtToWidth(); + QobuzBackend *m_backend = nullptr; QLabel *m_albumArt = nullptr; QLabel *m_title = nullptr; QLabel *m_artist = nullptr; QNetworkAccessManager *m_nam = nullptr; QString m_currentArtUrl; + QPixmap m_artPixmap; }; } // namespace Context From 932714702107801b23f0ff07057ad641ba48d143 Mon Sep 17 00:00:00 2001 From: joren Date: Tue, 24 Mar 2026 17:15:49 +0100 Subject: [PATCH 08/10] 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 --- src/playqueue.hpp | 34 +++++++++++++++++++++++++--------- src/view/context/view.cpp | 11 +++++++---- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/playqueue.hpp b/src/playqueue.hpp index 0bdb84b..e960f68 100644 --- a/src/playqueue.hpp +++ b/src/playqueue.hpp @@ -25,14 +25,27 @@ public: { m_queue.clear(); m_playNext.clear(); - for (const auto &v : tracks) - m_queue.append(v.toObject()); - if (m_shuffle) { - shuffleQueue(startIndex); - // shuffleQueue moves the start track to index 0 and sets m_index = 0 - } else { - m_index = qBound(0, startIndex, m_queue.size() - 1); + + // Only queue streamable tracks; find the filtered index for startIndex + int filteredStart = 0; + int filteredIdx = 0; + bool found = false; + for (int orig = 0; orig < tracks.size(); ++orig) { + const QJsonObject t = tracks[orig].toObject(); + if (!t["streamable"].toBool(true)) + continue; + if (!found && orig >= startIndex) { + filteredStart = filteredIdx; + found = true; + } + m_queue.append(t); + ++filteredIdx; } + m_index = qBound(0, filteredStart, qMax(0, m_queue.size() - 1)); + + if (m_shuffle) + shuffleQueue(m_index); + emit queueChanged(); } @@ -41,8 +54,11 @@ public: void reorderContext(const QJsonArray &tracks, qint64 currentId) { m_queue.clear(); - for (const auto &v : tracks) - m_queue.append(v.toObject()); + for (const auto &v : tracks) { + const QJsonObject t = v.toObject(); + if (t["streamable"].toBool(true)) + m_queue.append(t); + } m_index = 0; for (int i = 0; i < m_queue.size(); ++i) { diff --git a/src/view/context/view.cpp b/src/view/context/view.cpp index 62a2809..381b653 100644 --- a/src/view/context/view.cpp +++ b/src/view/context/view.cpp @@ -84,15 +84,18 @@ void View::onArtReady(QNetworkReply *reply) void View::resizeEvent(QResizeEvent *event) { QDockWidget::resizeEvent(event); - scaleArtToWidth(); + 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; - // Available width = dock width minus the 8px margins on each side - const int side = qMax(32, widget() ? widget()->width() - 16 : width() - 16); - m_albumArt->setFixedSize(side, side); + const int side = qMax(32, width() - 16); + m_albumArt->setFixedHeight(side); m_albumArt->setPixmap(m_artPixmap.scaled(side, side, Qt::KeepAspectRatio, Qt::SmoothTransformation)); } From 75429faffef8cc16ece35fc66b6fd4492891fd3b Mon Sep 17 00:00:00 2001 From: joren Date: Tue, 24 Mar 2026 17:20:00 +0100 Subject: [PATCH 09/10] feat: playlist art fix + Play/Shuffle buttons in context header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- rust/src/api/models.rs | 7 +++- src/list/tracks.cpp | 11 ++++++ src/list/tracks.hpp | 4 +++ src/view/maincontent.cpp | 6 ++++ src/view/trackcontextheader.hpp | 62 ++++++++++++++++++++++++++------- 5 files changed, 77 insertions(+), 13 deletions(-) diff --git a/rust/src/api/models.rs b/rust/src/api/models.rs index 2d8e2c4..33e0bd5 100644 --- a/rust/src/api/models.rs +++ b/rust/src/api/models.rs @@ -183,7 +183,12 @@ pub struct PlaylistDto { pub duration: Option, pub description: Option, pub owner: Option, - pub image_rectangle: Option>, + /// 4-cover mosaic at 300 px (preferred) + pub images300: Option>, + /// 4-cover mosaic at 150 px (fallback) + pub images150: Option>, + /// 4-cover mosaic at 50 px (last resort) + pub images: Option>, pub tracks: Option, } diff --git a/src/list/tracks.cpp b/src/list/tracks.cpp index 27e9926..0f49387 100644 --- a/src/list/tracks.cpp +++ b/src/list/tracks.cpp @@ -84,6 +84,17 @@ void Tracks::setPlayingTrackId(qint64 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(m_queue->current()["id"].toDouble()); + if (firstId > 0) + emit playTrackRequested(firstId); +} + void Tracks::onDoubleClicked(const QModelIndex &index) { diff --git a/src/list/tracks.hpp b/src/list/tracks.hpp index 5df2f9a..86f5095 100644 --- a/src/list/tracks.hpp +++ b/src/list/tracks.hpp @@ -28,6 +28,10 @@ namespace List /// Called when the backend fires EV_TRACK_CHANGED so the playing row is highlighted. 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); diff --git a/src/view/maincontent.cpp b/src/view/maincontent.cpp index d26170f..11131cf 100644 --- a/src/view/maincontent.cpp +++ b/src/view/maincontent.cpp @@ -1,6 +1,7 @@ #include "maincontent.hpp" #include +#include MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *parent) : QWidget(parent) @@ -31,6 +32,11 @@ MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *paren 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_artistList = new ArtistListView(this); m_artistView = new ArtistView(this); diff --git a/src/view/trackcontextheader.hpp b/src/view/trackcontextheader.hpp index 440854d..54ae319 100644 --- a/src/view/trackcontextheader.hpp +++ b/src/view/trackcontextheader.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -15,14 +16,14 @@ #include /// Header strip shown above the track list when an album or playlist is open. -/// Displays album art, title, subtitle, and metadata. +/// Displays album art, title, subtitle, metadata, and Play/Shuffle buttons. class TrackContextHeader : public QWidget { public: explicit TrackContextHeader(QWidget *parent = nullptr) : QWidget(parent) { - setFixedHeight(140); + setFixedHeight(148); auto *hlay = new QHBoxLayout(this); hlay->setContentsMargins(12, 8, 12, 8); @@ -61,7 +62,35 @@ public: m_meta->setPalette(mp); vlay->addWidget(m_meta); - vlay->addStretch(); + // 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); @@ -76,6 +105,9 @@ public: }); } + QPushButton *playButton() { return m_playBtn; } + QPushButton *shuffleButton() { return m_shuffleBtn; } + void setAlbum(const QJsonObject &album) { m_title->setText(album["title"].toString()); @@ -93,9 +125,14 @@ public: m_subtitle->setText(desc.isEmpty() ? owner : desc); m_meta->setText(buildPlaylistMeta(playlist)); - const QJsonArray imgs = playlist["image_rectangle"].toArray(); - if (!imgs.isEmpty()) - fetchUrl(imgs.first().toString()); + // 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()); @@ -140,7 +177,6 @@ private: if (tracks > 0) parts << QStringLiteral("%1 tracks").arg(tracks); const int dur = static_cast(album["duration"].toDouble()); if (dur > 0) parts << formatDuration(dur); - // Resolution: e.g. "24-bit / 96 kHz" const int bits = album["maximum_bit_depth"].toInt(); const double rate = album["maximum_sampling_rate"].toDouble(); if (bits > 0 && rate > 0) { @@ -162,10 +198,12 @@ private: return parts.join(QStringLiteral(" · ")); } - QLabel *m_art = nullptr; - QLabel *m_title = nullptr; - QLabel *m_subtitle = nullptr; - QLabel *m_meta = nullptr; - QNetworkAccessManager *m_nam = nullptr; + 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; }; From 56473cae6f09f06d47e3bee9062b7799f3f7a3fa Mon Sep 17 00:00:00 2001 From: joren Date: Tue, 24 Mar 2026 17:56:47 +0100 Subject: [PATCH 10/10] feat: artist sections, fav indicator, art scaling fix, volume popup fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Artist profile: collapsible Albums / EPs & Singles / Other sections keyed on release_type; fetches up to 200 albums per artist - Favorites: starred icon on favorited tracks, context menu shows Add or Remove (not both); IDs cached when fav tracks are loaded - Shuffle button: one-time shuffle via shuffleNow() without touching global shuffle flag, so double-click still plays in order - Now-playing art: replaced setFixedHeight hack with ArtWidget that overrides hasHeightForWidth() — scales smoothly up and down, no min-size - Volume popup: replaced QMenu (laggy, broken drag) with Qt::Popup QFrame; appears below button; fixed size locked at 100% label width Co-Authored-By: Claude Sonnet 4.6 --- rust/src/api/client.rs | 1 + rust/src/api/models.rs | 1 + src/CMakeLists.txt | 1 + src/list/tracks.cpp | 43 +++++++--- src/list/tracks.hpp | 6 ++ src/mainwindow.cpp | 9 +++ src/mainwindow.hpp | 2 + src/model/tracklistmodel.cpp | 34 ++++++++ src/model/tracklistmodel.hpp | 7 ++ src/playqueue.hpp | 8 ++ src/view/artistview.cpp | 153 +++++++++++++++++++++++++++++++++++ src/view/artistview.hpp | 80 ++++++++---------- src/view/context/view.cpp | 31 +------ src/view/context/view.hpp | 44 ++++++++-- src/widget/volumebutton.hpp | 59 +++++++++----- 15 files changed, 370 insertions(+), 109 deletions(-) create mode 100644 src/view/artistview.cpp diff --git a/rust/src/api/client.rs b/rust/src/api/client.rs index 37ab507..123d759 100644 --- a/rust/src/api/client.rs +++ b/rust/src/api/client.rs @@ -258,6 +258,7 @@ impl QobuzClient { .query(&[ ("artist_id", artist_id.to_string()), ("extra", "albums".to_string()), + ("albums_limit", "200".to_string()), ]) .send() .await?; diff --git a/rust/src/api/models.rs b/rust/src/api/models.rs index 33e0bd5..94a7ff6 100644 --- a/rust/src/api/models.rs +++ b/rust/src/api/models.rs @@ -98,6 +98,7 @@ pub struct AlbumDto { pub maximum_sampling_rate: Option, pub hires_streamable: Option, pub streamable: Option, + pub release_type: Option, pub tracks: Option, } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f494cc0..a9e52c2 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -25,6 +25,7 @@ target_sources(qobuz-qt PRIVATE view/albumlistview.hpp view/artistlistview.hpp view/artistview.hpp + view/artistview.cpp view/sidepanel/view.hpp view/sidepanel/view.cpp diff --git a/src/list/tracks.cpp b/src/list/tracks.cpp index 0f49387..539de62 100644 --- a/src/list/tracks.cpp +++ b/src/list/tracks.cpp @@ -84,12 +84,30 @@ void Tracks::setPlayingTrackId(qint64 id) m_model->setPlayingId(id); } +void Tracks::setFavTrackIds(const QSet &ids) +{ + m_model->setFavIds(ids); +} + +void Tracks::addFavTrackId(qint64 id) +{ + m_model->addFavId(id); +} + +void Tracks::removeFavTrackId(qint64 id) +{ + m_model->removeFavId(id); +} + void Tracks::playAll(bool shuffle) { const QJsonArray tracks = m_model->currentTracksJson(); if (tracks.isEmpty()) return; - m_queue->setShuffle(shuffle); m_queue->setContext(tracks, 0); + // Shuffle once without touching the global shuffle flag — so a subsequent + // double-click on a track plays in normal order (unless global shuffle is on). + if (shuffle && !m_queue->shuffleEnabled()) + m_queue->shuffleNow(); const qint64 firstId = static_cast(m_queue->current()["id"].toDouble()); if (firstId > 0) emit playTrackRequested(firstId); @@ -119,8 +137,21 @@ void Tracks::onContextMenu(const QPoint &pos) auto *playNext = menu.addAction(QIcon(":/res/icons/media-skip-forward.svg"), tr("Play next")); auto *addQueue = menu.addAction(QIcon(":/res/icons/media-playlist-append.svg"), tr("Add to queue")); menu.addSeparator(); - auto *addFav = menu.addAction(QIcon(":/res/icons/starred-symbolic.svg"), tr("Add to favorites")); - auto *remFav = menu.addAction(QIcon(":/res/icons/non-starred-symbolic.svg"), tr("Remove from favorites")); + + const bool isFav = m_model->isFav(id); + if (isFav) { + auto *remFav = menu.addAction(QIcon(":/res/icons/non-starred-symbolic.svg"), tr("Remove from favorites")); + connect(remFav, &QAction::triggered, this, [this, id] { + m_backend->removeFavTrack(id); + m_model->removeFavId(id); + }); + } else { + auto *addFav = menu.addAction(QIcon(":/res/icons/starred-symbolic.svg"), tr("Add to favorites")); + connect(addFav, &QAction::triggered, this, [this, id] { + m_backend->addFavTrack(id); + m_model->addFavId(id); + }); + } const int row = index.row(); connect(playNow, &QAction::triggered, this, [this, id, row] { @@ -133,12 +164,6 @@ void Tracks::onContextMenu(const QPoint &pos) connect(addQueue, &QAction::triggered, this, [this, trackJson] { m_queue->addToQueue(trackJson); }); - connect(addFav, &QAction::triggered, this, [this, id] { - m_backend->addFavTrack(id); - }); - connect(remFav, &QAction::triggered, this, [this, id] { - m_backend->removeFavTrack(id); - }); // Open album const QString albumId = m_model->trackAt(index.row()).albumId; diff --git a/src/list/tracks.hpp b/src/list/tracks.hpp index 86f5095..77687ac 100644 --- a/src/list/tracks.hpp +++ b/src/list/tracks.hpp @@ -10,6 +10,7 @@ #include #include #include +#include namespace List { @@ -28,6 +29,11 @@ namespace List /// Called when the backend fires EV_TRACK_CHANGED so the playing row is highlighted. void setPlayingTrackId(qint64 id); + /// Populate favorite track IDs so the star indicator and context menu reflect fav status. + void setFavTrackIds(const QSet &ids); + void addFavTrackId(qint64 id); + void removeFavTrackId(qint64 id); + /// Start playing all tracks in the current view from the beginning. /// If shuffle is true, enables shuffle mode before starting. void playAll(bool shuffle = false); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 0838733..5a14505 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -283,6 +283,15 @@ void MainWindow::onTrackChanged(const QJsonObject &track) void MainWindow::onFavTracksLoaded(const QJsonObject &result) { + // Cache fav IDs so the star indicator and context menu stay in sync + QSet ids; + const QJsonArray items = result["items"].toArray(); + for (const QJsonValue &v : items) { + const qint64 id = static_cast(v.toObject()["id"].toDouble()); + if (id > 0) ids.insert(id); + } + m_content->tracksList()->setFavTrackIds(ids); + m_content->showFavTracks(result); statusBar()->showMessage( tr("%1 favorite tracks").arg(result["total"].toInt()), 4000); diff --git a/src/mainwindow.hpp b/src/mainwindow.hpp index d5a0e02..ad1a15c 100644 --- a/src/mainwindow.hpp +++ b/src/mainwindow.hpp @@ -13,7 +13,9 @@ #include #include #include +#include #include +#include #include #include diff --git a/src/model/tracklistmodel.cpp b/src/model/tracklistmodel.cpp index 913cd81..2d5a32f 100644 --- a/src/model/tracklistmodel.cpp +++ b/src/model/tracklistmodel.cpp @@ -77,6 +77,36 @@ void TrackListModel::removeTrack(int row) endRemoveRows(); } +void TrackListModel::setFavIds(const QSet &ids) +{ + m_favIds = ids; + if (!m_tracks.isEmpty()) + emit dataChanged(index(0, ColTitle), index(rowCount() - 1, ColTitle), + {Qt::DecorationRole}); +} + +void TrackListModel::addFavId(qint64 id) +{ + m_favIds.insert(id); + for (int r = 0; r < m_tracks.size(); ++r) { + if (m_tracks[r].id == id) { + const auto idx = index(r, ColTitle); + emit dataChanged(idx, idx, {Qt::DecorationRole}); + } + } +} + +void TrackListModel::removeFavId(qint64 id) +{ + m_favIds.remove(id); + for (int r = 0; r < m_tracks.size(); ++r) { + if (m_tracks[r].id == id) { + const auto idx = index(r, ColTitle); + emit dataChanged(idx, idx, {Qt::DecorationRole}); + } + } +} + void TrackListModel::setPlayingId(qint64 id) { m_playingId = id; @@ -128,6 +158,10 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const return QIcon(QStringLiteral(":/res/icons/media-track-show-active.svg")); } + if (role == Qt::DecorationRole && index.column() == ColTitle && m_favIds.contains(t.id)) { + return QIcon(QStringLiteral(":/res/icons/starred-symbolic.svg")); + } + if (role == TrackIdRole) return t.id; if (role == TrackJsonRole) return t.raw; if (role == HiResRole) return t.hiRes; diff --git a/src/model/tracklistmodel.hpp b/src/model/tracklistmodel.hpp index 21b0003..916180a 100644 --- a/src/model/tracklistmodel.hpp +++ b/src/model/tracklistmodel.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include struct TrackItem { @@ -52,6 +53,11 @@ public: void setPlayingId(qint64 id); qint64 playingId() const { return m_playingId; } + void setFavIds(const QSet &ids); + void addFavId(qint64 id); + void removeFavId(qint64 id); + bool isFav(qint64 id) const { return m_favIds.contains(id); } + /// Optimistically remove a row (e.g. after deleting from playlist). void removeTrack(int row); @@ -81,6 +87,7 @@ signals: private: QVector m_tracks; + QSet m_favIds; qint64 m_playingId = 0; int m_sortColumn = -1; Qt::SortOrder m_sortOrder = Qt::AscendingOrder; diff --git a/src/playqueue.hpp b/src/playqueue.hpp index e960f68..cbe6a63 100644 --- a/src/playqueue.hpp +++ b/src/playqueue.hpp @@ -107,6 +107,14 @@ public: emit queueChanged(); } + /// Shuffle the current queue once without changing the global shuffle flag. + void shuffleNow() + { + if (m_queue.isEmpty()) return; + shuffleQueue(m_index); + emit queueChanged(); + } + // ---- Play-next prepend queue (like "Add to queue" ---- void addToQueue(const QJsonObject &track) diff --git a/src/view/artistview.cpp b/src/view/artistview.cpp new file mode 100644 index 0000000..684bd24 --- /dev/null +++ b/src/view/artistview.cpp @@ -0,0 +1,153 @@ +#include "artistview.hpp" +#include "albumlistview.hpp" + +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// ArtistSection +// --------------------------------------------------------------------------- + +ArtistSection::ArtistSection(const QString &title, QWidget *parent) + : QWidget(parent) + , m_baseTitle(title) +{ + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + m_toggle = new QToolButton(this); + m_toggle->setCheckable(true); + m_toggle->setChecked(true); + m_toggle->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + m_toggle->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + m_toggle->setStyleSheet(QStringLiteral( + "QToolButton { text-align: left; font-weight: bold; padding: 4px 6px;" + " border: none; border-bottom: 1px solid #333; }" + "QToolButton:hover { background: #1e1e1e; }" + )); + updateToggleText(0); + layout->addWidget(m_toggle); + + m_list = new AlbumListView(this); + layout->addWidget(m_list); + + connect(m_toggle, &QToolButton::toggled, m_list, &AlbumListView::setVisible); + connect(m_list, &AlbumListView::albumSelected, this, &ArtistSection::albumSelected); +} + +void ArtistSection::setAlbums(const QJsonArray &albums) +{ + m_list->setAlbums(albums); + updateToggleText(albums.size()); +} + +bool ArtistSection::isEmpty() const +{ + return m_list->topLevelItemCount() == 0; +} + +void ArtistSection::updateToggleText(int count) +{ + const QString arrow = m_toggle->isChecked() ? QStringLiteral("▼ ") : QStringLiteral("▶ "); + const QString text = count > 0 + ? QStringLiteral("%1%2 (%3)").arg(arrow, m_baseTitle).arg(count) + : arrow + m_baseTitle; + m_toggle->setText(text); + + // Keep arrow in sync when toggled + disconnect(m_toggle, &QToolButton::toggled, nullptr, nullptr); + connect(m_toggle, &QToolButton::toggled, m_list, &AlbumListView::setVisible); + connect(m_toggle, &QToolButton::toggled, this, [this, count](bool open) { + const QString a = open ? QStringLiteral("▼ ") : QStringLiteral("▶ "); + const QString t = count > 0 + ? QStringLiteral("%1%2 (%3)").arg(a, m_baseTitle).arg(count) + : a + m_baseTitle; + m_toggle->setText(t); + }); +} + +// --------------------------------------------------------------------------- +// ArtistView +// --------------------------------------------------------------------------- + +ArtistView::ArtistView(QWidget *parent) + : QWidget(parent) +{ + auto *outerLayout = new QVBoxLayout(this); + outerLayout->setContentsMargins(8, 8, 8, 8); + outerLayout->setSpacing(6); + + m_nameLabel = new QLabel(this); + QFont f = m_nameLabel->font(); + f.setPointSize(f.pointSize() + 4); + f.setBold(true); + m_nameLabel->setFont(f); + outerLayout->addWidget(m_nameLabel); + + m_bioLabel = new QLabel(this); + m_bioLabel->setWordWrap(true); + m_bioLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft); + m_bioLabel->setMaximumHeight(80); + outerLayout->addWidget(m_bioLabel); + + // Scrollable sections area + auto *scroll = new QScrollArea(this); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + + auto *content = new QWidget(scroll); + auto *sectLayout = new QVBoxLayout(content); + sectLayout->setContentsMargins(0, 0, 0, 0); + sectLayout->setSpacing(8); + + m_secAlbums = new ArtistSection(tr("Albums"), content); + m_secEps = new ArtistSection(tr("EPs & Singles"), content); + m_secOther = new ArtistSection(tr("Other"), content); + + sectLayout->addWidget(m_secAlbums); + sectLayout->addWidget(m_secEps); + sectLayout->addWidget(m_secOther); + sectLayout->addStretch(); + + scroll->setWidget(content); + outerLayout->addWidget(scroll, 1); + + connect(m_secAlbums, &ArtistSection::albumSelected, this, &ArtistView::albumSelected); + connect(m_secEps, &ArtistSection::albumSelected, this, &ArtistView::albumSelected); + connect(m_secOther, &ArtistSection::albumSelected, this, &ArtistView::albumSelected); +} + +void ArtistView::setArtist(const QJsonObject &artist) +{ + m_nameLabel->setText(artist["name"].toString()); + + const QString summary = artist["biography"].toObject()["summary"].toString(); + m_bioLabel->setText(summary); + m_bioLabel->setVisible(!summary.isEmpty()); + + const QJsonArray allAlbums = artist["albums"].toObject()["items"].toArray(); + + QJsonArray albums, eps, other; + for (const QJsonValue &v : allAlbums) { + const QJsonObject a = v.toObject(); + const QString rt = a["release_type"].toString(); + if (rt == QStringLiteral("album")) + albums.append(a); + else if (rt == QStringLiteral("epSingle")) + eps.append(a); + else + other.append(a); + } + + m_secAlbums->setAlbums(albums); + m_secEps->setAlbums(eps); + m_secOther->setAlbums(other); + + m_secAlbums->setVisible(!m_secAlbums->isEmpty()); + m_secEps->setVisible(!m_secEps->isEmpty()); + m_secOther->setVisible(!m_secOther->isEmpty()); +} diff --git a/src/view/artistview.hpp b/src/view/artistview.hpp index 8df1589..555dee0 100644 --- a/src/view/artistview.hpp +++ b/src/view/artistview.hpp @@ -3,62 +3,52 @@ #include "albumlistview.hpp" #include -#include #include -#include +#include #include #include -/// Artist detail page: name, biography summary, and their album list. -class ArtistView : public QWidget +class AlbumListView; + +/// One collapsible section (e.g. "Albums", "EPs & Singles") inside ArtistView. +class ArtistSection : public QWidget { Q_OBJECT - public: - explicit ArtistView(QWidget *parent = nullptr) : QWidget(parent) - { - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(8, 8, 8, 8); - layout->setSpacing(6); + explicit ArtistSection(const QString &title, QWidget *parent = nullptr); - m_nameLabel = new QLabel(this); - QFont f = m_nameLabel->font(); - f.setPointSize(f.pointSize() + 4); - f.setBold(true); - m_nameLabel->setFont(f); - - m_bioLabel = new QLabel(this); - m_bioLabel->setWordWrap(true); - m_bioLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft); - m_bioLabel->setMaximumHeight(80); - - m_albums = new AlbumListView(this); - - layout->addWidget(m_nameLabel); - layout->addWidget(m_bioLabel); - layout->addWidget(m_albums, 1); - - connect(m_albums, &AlbumListView::albumSelected, - this, &ArtistView::albumSelected); - } - - void setArtist(const QJsonObject &artist) - { - m_nameLabel->setText(artist["name"].toString()); - - const QString summary = artist["biography"].toObject()["summary"].toString(); - m_bioLabel->setText(summary); - m_bioLabel->setVisible(!summary.isEmpty()); - - const QJsonArray albums = artist["albums"].toObject()["items"].toArray(); - m_albums->setAlbums(albums); - } + void setAlbums(const QJsonArray &albums); + bool isEmpty() const; signals: void albumSelected(const QString &albumId); private: - QLabel *m_nameLabel = nullptr; - QLabel *m_bioLabel = nullptr; - AlbumListView *m_albums = nullptr; + QString m_baseTitle; + QToolButton *m_toggle = nullptr; + AlbumListView *m_list = nullptr; + + void updateToggleText(int count); +}; + +/// Artist detail page: name, biography, and albums split into collapsible sections +/// (Albums / EPs & Singles / Other) keyed on the release_type field. +class ArtistView : public QWidget +{ + Q_OBJECT + +public: + explicit ArtistView(QWidget *parent = nullptr); + + void setArtist(const QJsonObject &artist); + +signals: + void albumSelected(const QString &albumId); + +private: + QLabel *m_nameLabel = nullptr; + QLabel *m_bioLabel = nullptr; + ArtistSection *m_secAlbums = nullptr; + ArtistSection *m_secEps = nullptr; + ArtistSection *m_secOther = nullptr; }; diff --git a/src/view/context/view.cpp b/src/view/context/view.cpp index 381b653..e7490d8 100644 --- a/src/view/context/view.cpp +++ b/src/view/context/view.cpp @@ -22,11 +22,7 @@ View::View(QobuzBackend *backend, QWidget *parent) layout->setContentsMargins(8, 8, 8, 8); layout->setSpacing(6); - m_albumArt = new QLabel(container); - m_albumArt->setAlignment(Qt::AlignCenter); - m_albumArt->setStyleSheet(QStringLiteral( - "background: #1a1a1a; border-radius: 4px;")); - m_albumArt->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + m_albumArt = new ArtWidget(container); layout->addWidget(m_albumArt); m_title = new QLabel(tr("Not playing"), container); @@ -45,7 +41,6 @@ View::View(QobuzBackend *backend, QWidget *parent) layout->addStretch(); setWidget(container); - setMinimumWidth(160); connect(m_backend, &QobuzBackend::trackChanged, this, &View::onTrackChanged); } @@ -60,7 +55,6 @@ void View::onTrackChanged(const QJsonObject &track) m_title->setText(title.isEmpty() ? tr("Not playing") : title); m_artist->setText(artist); - // Prefer "large" image, fall back to "small" const QJsonObject img = track["album"].toObject()["image"].toObject(); QString artUrl = img["large"].toString(); if (artUrl.isEmpty()) @@ -77,26 +71,9 @@ void View::onArtReady(QNetworkReply *reply) reply->deleteLater(); if (reply->error() != QNetworkReply::NoError) return; - if (m_artPixmap.loadFromData(reply->readAll())) - scaleArtToWidth(); -} - -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)); + QPixmap pix; + if (pix.loadFromData(reply->readAll())) + m_albumArt->setPixmap(pix); } } // namespace Context diff --git a/src/view/context/view.hpp b/src/view/context/view.hpp index c2012b4..156ad88 100644 --- a/src/view/context/view.hpp +++ b/src/view/context/view.hpp @@ -3,15 +3,49 @@ #include "../../backend/qobuzbackend.hpp" #include +#include #include #include -#include +#include +#include #include #include #include namespace Context { + /// Square art widget: always as wide as its parent allows, height follows width. + class ArtWidget : public QWidget + { + public: + explicit ArtWidget(QWidget *parent = nullptr) : QWidget(parent) + { + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + } + + void setPixmap(const QPixmap &px) { m_pix = px; update(); } + bool hasHeightForWidth() const override { return true; } + int heightForWidth(int w) const override { return w; } + + protected: + void paintEvent(QPaintEvent *) override + { + QPainter p(this); + if (m_pix.isNull()) { + p.fillRect(rect(), QColor(0x1a, 0x1a, 0x1a)); + return; + } + const QPixmap scaled = m_pix.scaled(size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); + p.fillRect(rect(), QColor(0x1a, 0x1a, 0x1a)); + p.drawPixmap((width() - scaled.width()) / 2, + (height() - scaled.height()) / 2, + scaled); + } + + private: + QPixmap m_pix; + }; + class View : public QDockWidget { Q_OBJECT @@ -23,18 +57,12 @@ namespace Context void onTrackChanged(const QJsonObject &track); void onArtReady(QNetworkReply *reply); - protected: - void resizeEvent(QResizeEvent *event) override; - private: - void scaleArtToWidth(); - QobuzBackend *m_backend = nullptr; - QLabel *m_albumArt = nullptr; + ArtWidget *m_albumArt = nullptr; QLabel *m_title = nullptr; QLabel *m_artist = nullptr; QNetworkAccessManager *m_nam = nullptr; QString m_currentArtUrl; - QPixmap m_artPixmap; }; } // namespace Context diff --git a/src/widget/volumebutton.hpp b/src/widget/volumebutton.hpp index 3a4bef5..09790ae 100644 --- a/src/widget/volumebutton.hpp +++ b/src/widget/volumebutton.hpp @@ -4,10 +4,9 @@ #include "../util/icon.hpp" #include -#include -#include -#include +#include #include +#include /// A toolbar button that shows a volume slider popup when clicked. class VolumeButton : public QToolButton @@ -17,33 +16,36 @@ class VolumeButton : public QToolButton public: explicit VolumeButton(QWidget *parent = nullptr) : QToolButton(parent) { - setPopupMode(QToolButton::InstantPopup); setIcon(Icon::volumeHigh()); - auto *menu = new QMenu(this); - auto *widget = new QWidget(menu); - widget->setMinimumWidth(72); - auto *layout = new QVBoxLayout(widget); - layout->setContentsMargins(6, 6, 6, 6); + // Qt::Popup closes automatically when the user clicks outside. + m_popup = new QFrame(this, Qt::Popup); + m_popup->setFrameShape(QFrame::StyledPanel); + m_popup->setFrameShadow(QFrame::Raised); - m_label = new QLabel("80%", widget); + auto *layout = new QVBoxLayout(m_popup); + layout->setContentsMargins(10, 10, 10, 10); + layout->setSpacing(6); + + m_label = new QLabel(QStringLiteral("80%"), m_popup); m_label->setAlignment(Qt::AlignCenter); + layout->addWidget(m_label); - m_slider = new ClickableSlider(Qt::Vertical, widget); + m_slider = new ClickableSlider(Qt::Vertical, m_popup); m_slider->setRange(0, 100); m_slider->setValue(80); m_slider->setFixedHeight(120); + layout->addWidget(m_slider, 0, Qt::AlignHCenter); - layout->addWidget(m_label); - layout->addWidget(m_slider); - - auto *action = new QWidgetAction(menu); - action->setDefaultWidget(widget); - menu->addAction(action); - setMenu(menu); + // Size the popup at its maximum (label = "100%") and lock it + m_label->setText(QStringLiteral("100%")); + m_popup->adjustSize(); + m_popup->setFixedSize(m_popup->sizeHint()); + m_label->setText(QStringLiteral("80%")); + connect(this, &QToolButton::clicked, this, &VolumeButton::togglePopup); connect(m_slider, &QSlider::valueChanged, this, [this](int v) { - m_label->setText(QString::number(v) + "%"); + m_label->setText(QString::number(v) + QStringLiteral("%")); updateIcon(v); emit volumeChanged(v); }); @@ -56,14 +58,31 @@ public: m_slider->blockSignals(true); m_slider->setValue(v); m_slider->blockSignals(false); - m_label->setText(QString::number(v) + "%"); + m_label->setText(QString::number(v) + QStringLiteral("%")); updateIcon(v); } signals: void volumeChanged(int volume); +private slots: + void togglePopup() + { + if (m_popup->isVisible()) { + m_popup->hide(); + return; + } + // Centre popup horizontally over button, place below it + const QPoint global = mapToGlobal( + QPoint(width() / 2 - m_popup->width() / 2, + height() + 4)); + m_popup->move(global); + m_popup->show(); + m_popup->raise(); + } + private: + QFrame *m_popup = nullptr; ClickableSlider *m_slider = nullptr; QLabel *m_label = nullptr;