From f30e2058c1546f1622265c17a3e52a0b4c78cef7 Mon Sep 17 00:00:00 2001 From: joren Date: Tue, 24 Mar 2026 11:19:32 +0100 Subject: [PATCH] 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); }