diff --git a/rust/include/qobuz_backend.h b/rust/include/qobuz_backend.h index 7ddba03..83d66a0 100644 --- a/rust/include/qobuz_backend.h +++ b/rust/include/qobuz_backend.h @@ -30,7 +30,10 @@ 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, + EV_PLAYLIST_TRACK_ADDED = 22, }; // Callback signature @@ -69,6 +72,17 @@ uint8_t qobuz_backend_get_volume(const QobuzBackendOpaque *backend); int qobuz_backend_get_state(const QobuzBackendOpaque *backend); int qobuz_backend_take_track_finished(QobuzBackendOpaque *backend); +// ReplayGain / Gapless +void qobuz_backend_set_replaygain(QobuzBackendOpaque *backend, bool enabled); +void qobuz_backend_set_gapless(QobuzBackendOpaque *backend, bool enabled); +void qobuz_backend_prefetch_track(QobuzBackendOpaque *backend, int64_t track_id, int32_t format_id); + +// 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..123d759 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())]); @@ -249,6 +258,7 @@ impl QobuzClient { .query(&[ ("artist_id", artist_id.to_string()), ("extra", "albums".to_string()), + ("albums_limit", "200".to_string()), ]) .send() .await?; @@ -329,6 +339,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..94a7ff6 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, @@ -97,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, } @@ -182,7 +184,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/rust/src/lib.rs b/rust/src/lib.rs index ad41159..e1044b5 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,46 @@ pub unsafe extern "C" fn qobuz_backend_take_track_finished(ptr: *mut Backend) -> if finished { 1 } else { 0 } } +// ---------- ReplayGain / Gapless ---------- + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_set_replaygain(ptr: *mut Backend, enabled: bool) { + (*ptr).0.replaygain_enabled.store(enabled, std::sync::atomic::Ordering::Relaxed); +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_set_gapless(ptr: *mut Backend, enabled: bool) { + (*ptr).0.player.status.gapless.store(enabled, std::sync::atomic::Ordering::Relaxed); +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_prefetch_track( + ptr: *mut Backend, + track_id: i64, + format_id: i32, +) { + let inner = &(*ptr).0; + let client = inner.client.clone(); + let prefetch = inner.prefetch.clone(); + let format = Format::from_id(format_id); + + spawn(inner, async move { + let track = match client.lock().await.get_track(track_id).await { + Ok(t) => t, + Err(_) => return, + }; + let url_dto = match client.lock().await.get_track_url(track_id, format).await { + Ok(u) => u, + Err(_) => return, + }; + let url = match url_dto.url { + Some(u) => u, + None => return, + }; + *prefetch.lock().await = Some(PrefetchedTrack { track_id, track, url }); + }); +} + // ---------- Favorites modification ---------- #[no_mangle] @@ -489,3 +562,71 @@ pub unsafe extern "C" fn qobuz_backend_remove_fav_album(ptr: *mut Backend, album } }); } + +// ---------- Playlist management ---------- + +pub const EV_PLAYLIST_CREATED: c_int = 20; +pub const EV_PLAYLIST_DELETED: c_int = 21; +pub const EV_PLAYLIST_TRACK_ADDED: c_int = 22; + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_create_playlist(ptr: *mut Backend, name: *const c_char) { + let inner = &(*ptr).0; + let name = CStr::from_ptr(name).to_string_lossy().into_owned(); + let client = inner.client.clone(); + let cb = inner.cb; let ud = inner.ud; + spawn(inner, async move { + match client.lock().await.create_playlist(&name).await { + Ok(p) => call_cb(cb, ud, EV_PLAYLIST_CREATED, &serde_json::to_string(&p).unwrap_or_default()), + Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())), + } + }); +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_delete_playlist(ptr: *mut Backend, playlist_id: i64) { + let inner = &(*ptr).0; + let client = inner.client.clone(); + let cb = inner.cb; let ud = inner.ud; + spawn(inner, async move { + match client.lock().await.delete_playlist(playlist_id).await { + Ok(()) => call_cb(cb, ud, EV_PLAYLIST_DELETED, + &serde_json::json!({"playlist_id": playlist_id}).to_string()), + Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())), + } + }); +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_add_track_to_playlist( + ptr: *mut Backend, + playlist_id: i64, + track_id: i64, +) { + let inner = &(*ptr).0; + let client = inner.client.clone(); + let cb = inner.cb; let ud = inner.ud; + spawn(inner, async move { + match client.lock().await.add_track_to_playlist(playlist_id, track_id).await { + Ok(()) => call_cb(cb, ud, EV_PLAYLIST_TRACK_ADDED, + &serde_json::json!({"playlist_id": playlist_id}).to_string()), + Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())), + } + }); +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_delete_track_from_playlist( + ptr: *mut Backend, + playlist_id: i64, + playlist_track_id: i64, +) { + let inner = &(*ptr).0; + let client = inner.client.clone(); + let cb = inner.cb; let ud = inner.ud; + spawn(inner, async move { + if let Err(e) = client.lock().await.delete_track_from_playlist(playlist_id, playlist_track_id).await { + call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())); + } + }); +} 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..872f1b5 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,10 @@ 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>, + /// When false the audio output is torn down after each track, producing a gap. + pub gapless: Arc, } impl PlayerStatus { @@ -60,6 +66,8 @@ 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)), + gapless: Arc::new(AtomicBool::new(false)), } } @@ -106,10 +114,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 +137,80 @@ impl Player { } } +/// The player loop runs on a single dedicated OS thread. +/// It owns the `AudioOutput` locally so there are no Send constraints. +/// Decoding is performed inline; the command channel is polled via try_recv +/// inside the decode loop to handle Pause/Resume/Seek/Stop/Play without +/// tearng down and re-opening the audio device between tracks. fn player_loop(rx: std::sync::mpsc::Receiver, 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()); - } - } - }); + *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 — tear down audio output if gapless is off + if !status.gapless.load(Ordering::Relaxed) { + audio_output = None; } - 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::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/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/backend/qobuzbackend.cpp b/src/backend/qobuzbackend.cpp index 5646c18..dd5b826 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,45 @@ void QobuzBackend::getUserPlaylists(quint32 offset, quint32 limit) qobuz_backend_get_user_playlists(m_backend, offset, limit); } +// ---- playback options ---- + +void QobuzBackend::setReplayGain(bool enabled) +{ + qobuz_backend_set_replaygain(m_backend, enabled); +} + +void QobuzBackend::setGapless(bool enabled) +{ + qobuz_backend_set_gapless(m_backend, enabled); +} + +void QobuzBackend::prefetchTrack(qint64 trackId, int formatId) +{ + qobuz_backend_prefetch_track(m_backend, trackId, formatId); +} + +// ---- playlist management ---- + +void QobuzBackend::createPlaylist(const QString &name) +{ + qobuz_backend_create_playlist(m_backend, name.toUtf8().constData()); +} + +void QobuzBackend::deletePlaylist(qint64 playlistId) +{ + qobuz_backend_delete_playlist(m_backend, playlistId); +} + +void QobuzBackend::addTrackToPlaylist(qint64 playlistId, qint64 trackId) +{ + qobuz_backend_add_track_to_playlist(m_backend, playlistId, trackId); +} + +void QobuzBackend::deleteTrackFromPlaylist(qint64 playlistId, qint64 playlistTrackId) +{ + qobuz_backend_delete_track_from_playlist(m_backend, playlistId, playlistTrackId); +} + // ---- fav modification ---- void QobuzBackend::addFavTrack(qint64 trackId) @@ -201,6 +240,15 @@ void QobuzBackend::onEvent(int eventType, const QString &json) case EV_STATE_CHANGED: emit stateChanged(obj["state"].toString()); break; + case 20: // EV_PLAYLIST_CREATED + emit playlistCreated(obj); + break; + case 21: // EV_PLAYLIST_DELETED + emit playlistDeleted(obj); + break; + case 22: // EV_PLAYLIST_TRACK_ADDED + emit playlistTrackAdded(static_cast(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 725a522..4ac0a29 100644 --- a/src/backend/qobuzbackend.hpp +++ b/src/backend/qobuzbackend.hpp @@ -36,6 +36,17 @@ public: void getFavArtists(quint32 offset = 0, quint32 limit = 200); void getUserPlaylists(quint32 offset = 0, quint32 limit = 200); + // --- playback options --- + void setReplayGain(bool enabled); + void setGapless(bool enabled); + void prefetchTrack(qint64 trackId, int formatId = 6); + + // --- playlist management --- + void createPlaylist(const QString &name); + void deletePlaylist(qint64 playlistId); + void addTrackToPlaylist(qint64 playlistId, qint64 trackId); + void deleteTrackFromPlaylist(qint64 playlistId, qint64 playlistTrackId); + // --- fav modification --- void addFavTrack(qint64 trackId); void removeFavTrack(qint64 trackId); @@ -66,6 +77,9 @@ 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); + void playlistTrackAdded(qint64 playlistId); // favorites void favTracksLoaded(const QJsonObject &result); diff --git a/src/dialog/settings.cpp b/src/dialog/settings.cpp index f3766d9..2b990a7 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"), 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..fdbe433 100644 --- a/src/list/library.cpp +++ b/src/list/library.cpp @@ -3,6 +3,9 @@ #include #include #include +#include +#include +#include namespace List { @@ -10,6 +13,7 @@ namespace List static constexpr int TypeRole = Qt::UserRole + 1; static constexpr int IdRole = Qt::UserRole + 2; static constexpr int NameRole = Qt::UserRole + 3; +static constexpr int IsOwnerRole = Qt::UserRole + 4; enum NodeType { NodeFavTracks, @@ -24,16 +28,29 @@ Library::Library(QobuzBackend *backend, QWidget *parent) { setHeaderHidden(true); setRootIsDecorated(true); - setContextMenuPolicy(Qt::NoContextMenu); + setContextMenuPolicy(Qt::CustomContextMenu); buildStaticNodes(); connect(m_backend, &QobuzBackend::userPlaylistsLoaded, this, &Library::onUserPlaylistsLoaded); + connect(m_backend, &QobuzBackend::playlistCreated, + this, [this](const QJsonObject &) { refresh(); }); + connect(m_backend, &QobuzBackend::playlistDeleted, + this, [this](const QJsonObject &result) { + const qint64 deletedId = static_cast(result["playlist_id"].toDouble()); + if (deletedId == m_openPlaylistId) { + m_openPlaylistId = 0; + emit openPlaylistDeleted(); + } + refresh(); + }); connect(this, &QTreeWidget::itemClicked, this, &Library::onItemClicked); connect(this, &QTreeWidget::itemDoubleClicked, this, &Library::onItemDoubleClicked); + connect(this, &QTreeWidget::customContextMenuRequested, + this, &Library::onContextMenuRequested); } void Library::buildStaticNodes() @@ -70,17 +87,75 @@ void Library::onUserPlaylistsLoaded(const QJsonObject &result) while (m_playlistsNode->childCount() > 0) delete m_playlistsNode->takeChild(0); + QVector> 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); + + // Only include playlists we can edit in the "Add to playlist" submenu + if (isOwner) + editablePlaylists.append({id, name}); } + + emit userPlaylistsChanged(editablePlaylists); +} + +void Library::onContextMenuRequested(const QPoint &pos) +{ + QTreeWidgetItem *item = itemAt(pos); + if (!item) return; + + const bool isHeader = (item == m_playlistsNode); + const bool isPlaylistItem = (!isHeader && item->parent() == m_playlistsNode && + item->data(0, TypeRole).toInt() == NodePlaylist); + + if (!isHeader && !isPlaylistItem) return; + + QMenu menu(this); + + auto *newPl = menu.addAction(tr("New Playlist…")); + connect(newPl, &QAction::triggered, this, [this] { + bool ok = false; + const QString name = QInputDialog::getText( + this, tr("New Playlist"), tr("Playlist name:"), + QLineEdit::Normal, QString(), &ok); + if (ok && !name.trimmed().isEmpty()) + m_backend->createPlaylist(name.trimmed()); + }); + + if (isPlaylistItem) { + const qint64 plId = item->data(0, IdRole).toLongLong(); + const QString plName = item->data(0, NameRole).toString(); + const bool isOwner = item->data(0, IsOwnerRole).toBool(); + + if (isOwner) { + menu.addSeparator(); + auto *delPl = menu.addAction(tr("Delete \"%1\"…").arg(plName)); + connect(delPl, &QAction::triggered, this, [this, plId, plName] { + const auto answer = QMessageBox::question( + this, + tr("Delete Playlist"), + tr("Permanently delete \"%1\"? This cannot be undone.").arg(plName), + QMessageBox::Yes | QMessageBox::Cancel, + QMessageBox::Cancel); + if (answer == QMessageBox::Yes) + m_backend->deletePlaylist(plId); + }); + } + } + + menu.exec(viewport()->mapToGlobal(pos)); } void Library::onItemClicked(QTreeWidgetItem *item, int) @@ -95,6 +170,7 @@ void Library::onItemClicked(QTreeWidgetItem *item, int) case NodePlaylist: { const qint64 id = item->data(0, IdRole).toLongLong(); const QString name = item->data(0, NameRole).toString(); + m_openPlaylistId = id; emit playlistRequested(id, name); break; } diff --git a/src/list/library.hpp b/src/list/library.hpp index fb6be76..529aa2c 100644 --- a/src/list/library.hpp +++ b/src/list/library.hpp @@ -1,8 +1,12 @@ #pragma once #include "../backend/qobuzbackend.hpp" +#include "../util/settings.hpp" #include +#include +#include +#include namespace List { @@ -23,17 +27,23 @@ namespace List void favAlbumsRequested(); void favArtistsRequested(); void playlistRequested(qint64 playlistId, const QString &name); + /// Emitted after playlists are loaded so others can cache the list. + void userPlaylistsChanged(const QVector> &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..539de62 100644 --- a/src/list/tracks.cpp +++ b/src/list/tracks.cpp @@ -1,4 +1,5 @@ #include "tracks.hpp" +#include "../util/settings.hpp" #include #include @@ -39,31 +40,79 @@ 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()); + 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); } void Tracks::loadSearchTracks(const QJsonArray &tracks) { + setPlaylistContext(0); m_model->setTracks(tracks, false, /*useSequential=*/true); } +void Tracks::setPlaylistContext(qint64 playlistId, bool isOwned) +{ + m_playlistId = playlistId; + m_playlistIsOwned = isOwned; +} + +void Tracks::setUserPlaylists(const QVector> &playlists) +{ + m_userPlaylists = playlists; +} + 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->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); +} + void Tracks::onDoubleClicked(const QModelIndex &index) { @@ -88,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] { @@ -102,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; @@ -134,6 +190,36 @@ void Tracks::onContextMenu(const QPoint &pos) }); } + // Playlist management + if (!m_userPlaylists.isEmpty()) { + menu.addSeparator(); + auto *addToPlMenu = menu.addMenu( + QIcon(":/res/icons/media-playlist-append.svg"), tr("Add to playlist")); + for (const auto &pl : m_userPlaylists) { + const qint64 plId = pl.first; + const QString plName = pl.second; + auto *act = addToPlMenu->addAction(plName); + connect(act, &QAction::triggered, this, [this, id, plId] { + emit addToPlaylistRequested(id, plId); + }); + } + } + + if (m_playlistId > 0 && m_playlistIsOwned) { + const qint64 playlistTrackId = + m_model->data(index, TrackListModel::PlaylistTrackIdRole).toLongLong(); + if (playlistTrackId > 0) { + if (m_userPlaylists.isEmpty()) menu.addSeparator(); + auto *remFromPl = menu.addAction(tr("Remove from this playlist")); + const qint64 curPlaylistId = m_playlistId; + const int curRow = index.row(); + connect(remFromPl, &QAction::triggered, this, [this, curPlaylistId, playlistTrackId, curRow] { + emit removeFromPlaylistRequested(curPlaylistId, playlistTrackId); + m_model->removeTrack(curRow); // optimistic: remove immediately from view + }); + } + } + menu.exec(viewport()->mapToGlobal(pos)); } diff --git a/src/list/tracks.hpp b/src/list/tracks.hpp index 4fa3755..77687ac 100644 --- a/src/list/tracks.hpp +++ b/src/list/tracks.hpp @@ -7,6 +7,10 @@ #include #include #include +#include +#include +#include +#include namespace List { @@ -25,13 +29,34 @@ namespace List /// Called when the backend fires EV_TRACK_CHANGED so the playing row is highlighted. void setPlayingTrackId(qint64 id); + /// Populate favorite track IDs so the star indicator and context menu reflect fav status. + void setFavTrackIds(const QSet &ids); + void addFavTrackId(qint64 id); + void removeFavTrackId(qint64 id); + + /// Start playing all tracks in the current view from the beginning. + /// If shuffle is true, enables shuffle mode before starting. + void playAll(bool shuffle = false); + + /// Set which playlist is currently displayed (0 = none). + /// isOwned controls whether "Remove from this playlist" is shown. + void setPlaylistContext(qint64 playlistId, bool isOwned = false); + qint64 playlistId() const { return m_playlistId; } + /// Provide the user's playlist list for the "Add to playlist" submenu. + void setUserPlaylists(const QVector> &playlists); + signals: void playTrackRequested(qint64 trackId); + void addToPlaylistRequested(qint64 trackId, qint64 playlistId); + void removeFromPlaylistRequested(qint64 playlistId, qint64 playlistTrackId); private: TrackListModel *m_model = nullptr; QobuzBackend *m_backend = nullptr; PlayQueue *m_queue = nullptr; + qint64 m_playlistId = 0; + bool m_playlistIsOwned = false; + QVector> 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..5a14505 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -77,12 +77,31 @@ 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 &) { + // status bar message is also shown by library's openPlaylistDeleted handler + }); + connect(m_backend, &QobuzBackend::playlistTrackAdded, this, [this](qint64 playlistId) { + // Refresh the currently shown playlist if a track was added to it + if (m_content->tracksList()->playlistId() == playlistId) + m_backend->getPlaylist(playlistId); + statusBar()->showMessage(tr("Track added to playlist"), 3000); + }); connect(m_backend, &QobuzBackend::trackChanged, this, &MainWindow::onTrackChanged); connect(m_backend, &QobuzBackend::error, this, [this](const QString &msg) { statusBar()->showMessage(tr("Error: %1").arg(msg), 6000); }); + // ---- Library signals ---- + connect(m_library, &List::Library::userPlaylistsChanged, + this, &MainWindow::onUserPlaylistsChanged); + connect(m_library, &List::Library::openPlaylistDeleted, + this, [this] { + m_content->showWelcome(); + statusBar()->showMessage(tr("Playlist deleted"), 3000); + }); + // ---- Library → backend ---- connect(m_library, &List::Library::favTracksRequested, this, [this] { m_backend->getFavTracks(); @@ -102,9 +121,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 +158,10 @@ 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()); + m_backend->setGapless(AppSettings::instance().gaplessEnabled()); + tryRestoreSession(); } @@ -210,6 +243,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(); @@ -233,10 +269,29 @@ void MainWindow::onTrackChanged(const QJsonObject &track) : track["performer"].toObject()["name"].toString(); statusBar()->showMessage( artist.isEmpty() ? title : QStringLiteral("▶ %1 — %2").arg(artist, title)); + + // Prefetch next track URL when gapless is enabled + if (AppSettings::instance().gaplessEnabled() && m_queue->canGoNext()) { + const auto upcoming = m_queue->upcomingTracks(1); + if (!upcoming.isEmpty()) { + const qint64 nextId = static_cast(upcoming.first()["id"].toDouble()); + if (nextId > 0) + m_backend->prefetchTrack(nextId, AppSettings::instance().preferredFormat()); + } + } } void MainWindow::onFavTracksLoaded(const QJsonObject &result) { + // Cache fav IDs so the star indicator and context menu stay in sync + QSet 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); @@ -298,3 +353,19 @@ void MainWindow::onSearchToggled(bool visible) { m_sidePanel->setVisible(visible); } + +void MainWindow::onPlaylistCreated(const QJsonObject &playlist) +{ + const QString name = playlist["name"].toString(); + statusBar()->showMessage(tr("Playlist '%1' created").arg(name), 4000); + // Open the new playlist immediately + const qint64 id = static_cast(playlist["id"].toDouble()); + if (id > 0) + m_backend->getPlaylist(id); +} + +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..ad1a15c 100644 --- a/src/mainwindow.hpp +++ b/src/mainwindow.hpp @@ -13,6 +13,11 @@ #include #include #include +#include +#include +#include +#include +#include class MainWindow : public QMainWindow { @@ -38,6 +43,8 @@ private slots: void onSearchAlbumSelected(const QString &albumId); void onSearchArtistSelected(qint64 artistId); void onSearchToggled(bool visible); + void onPlaylistCreated(const QJsonObject &playlist); + void onUserPlaylistsChanged(const QVector> &playlists); void showLoginDialog(); void showSettingsDialog(); @@ -45,6 +52,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..2d5a32f 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,44 @@ void TrackListModel::clear() endResetModel(); } +void TrackListModel::removeTrack(int row) +{ + if (row < 0 || row >= m_tracks.size()) return; + beginRemoveRows({}, row, row); + m_tracks.removeAt(row); + endRemoveRows(); +} + +void TrackListModel::setFavIds(const QSet &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; @@ -111,17 +150,22 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const } if (role == Qt::ForegroundRole) { - if (!t.streamable) return QColor(Qt::gray); - if (isPlaying) return QColor(0x1d, 0xb9, 0x54); // Qobuz green + if (!t.streamable) return QColor(0x55, 0x55, 0x55); + if (isPlaying) return QColor(0xFF, 0xB2, 0x32); // Qobuz orange } if (role == Qt::DecorationRole && index.column() == ColNumber && isPlaying) { return QIcon(QStringLiteral(":/res/icons/media-track-show-active.svg")); } - if (role == TrackIdRole) return t.id; - if (role == TrackJsonRole) return t.raw; - if (role == HiResRole) return t.hiRes; + if (role == Qt::DecorationRole && index.column() == ColTitle && m_favIds.contains(t.id)) { + return QIcon(QStringLiteral(":/res/icons/starred-symbolic.svg")); + } + + if (role == TrackIdRole) return t.id; + if (role == TrackJsonRole) return t.raw; + if (role == HiResRole) return t.hiRes; + if (role == PlaylistTrackIdRole) return t.playlistTrackId; return {}; } diff --git a/src/model/tracklistmodel.hpp b/src/model/tracklistmodel.hpp index 72f428c..916180a 100644 --- a/src/model/tracklistmodel.hpp +++ b/src/model/tracklistmodel.hpp @@ -4,18 +4,20 @@ #include #include #include +#include #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 +36,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 +53,14 @@ 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); + const TrackItem &trackAt(int row) const { return m_tracks.at(row); } // Returns the current (possibly sorted) raw JSON objects in display order. @@ -76,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 0bdb84b..cbe6a63 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) { @@ -91,6 +107,14 @@ public: emit queueChanged(); } + /// Shuffle the current queue once without changing the global shuffle flag. + void shuffleNow() + { + if (m_queue.isEmpty()) return; + shuffleQueue(m_index); + emit queueChanged(); + } + // ---- Play-next prepend queue (like "Add to queue" ---- void addToQueue(const QJsonObject &track) diff --git a/src/util/settings.hpp b/src/util/settings.hpp index d08a339..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); } @@ -31,6 +34,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/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 09204e9..e7490d8 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) @@ -24,13 +22,8 @@ View::View(QobuzBackend *backend, QWidget *parent) layout->setContentsMargins(8, 8, 8, 8); layout->setSpacing(6); - m_albumArt = new QLabel(container); - m_albumArt->setFixedSize(ArtSize, ArtSize); - m_albumArt->setScaledContents(true); - m_albumArt->setAlignment(Qt::AlignCenter); - m_albumArt->setStyleSheet(QStringLiteral( - "background: #1a1a1a; border-radius: 4px;")); - layout->addWidget(m_albumArt, 0, Qt::AlignCenter); + m_albumArt = new ArtWidget(container); + layout->addWidget(m_albumArt); m_title = new QLabel(tr("Not playing"), container); m_title->setAlignment(Qt::AlignCenter); @@ -48,7 +41,6 @@ View::View(QobuzBackend *backend, QWidget *parent) layout->addStretch(); setWidget(container); - setMinimumWidth(160); connect(m_backend, &QobuzBackend::trackChanged, this, &View::onTrackChanged); } @@ -63,7 +55,6 @@ void View::onTrackChanged(const QJsonObject &track) m_title->setText(title.isEmpty() ? tr("Not playing") : title); m_artist->setText(artist); - // Prefer "large" image, fall back to "small" const QJsonObject img = track["album"].toObject()["image"].toObject(); QString artUrl = img["large"].toString(); if (artUrl.isEmpty()) diff --git a/src/view/context/view.hpp b/src/view/context/view.hpp index 3ae00ba..156ad88 100644 --- a/src/view/context/view.hpp +++ b/src/view/context/view.hpp @@ -3,13 +3,49 @@ #include "../../backend/qobuzbackend.hpp" #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,7 +59,7 @@ namespace Context private: QobuzBackend *m_backend = nullptr; - QLabel *m_albumArt = nullptr; + ArtWidget *m_albumArt = nullptr; QLabel *m_title = nullptr; QLabel *m_artist = nullptr; QNetworkAccessManager *m_nam = nullptr; diff --git a/src/view/maincontent.cpp b/src/view/maincontent.cpp index 765d45a..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) @@ -19,13 +20,29 @@ 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); + + 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); 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 +58,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/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); } diff --git a/src/view/trackcontextheader.hpp b/src/view/trackcontextheader.hpp new file mode 100644 index 0000000..54ae319 --- /dev/null +++ b/src/view/trackcontextheader.hpp @@ -0,0 +1,209 @@ +#pragma once + +#include +#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, metadata, and Play/Shuffle buttons. +class TrackContextHeader : public QWidget +{ +public: + explicit TrackContextHeader(QWidget *parent = nullptr) + : QWidget(parent) + { + setFixedHeight(148); + + auto *hlay = new QHBoxLayout(this); + hlay->setContentsMargins(12, 8, 12, 8); + hlay->setSpacing(14); + + m_art = new QLabel(this); + m_art->setFixedSize(120, 120); + m_art->setScaledContents(true); + m_art->setAlignment(Qt::AlignCenter); + m_art->setStyleSheet(QStringLiteral("background: #1a1a1a; border-radius: 4px;")); + hlay->addWidget(m_art, 0, Qt::AlignVCenter); + + auto *info = new QWidget(this); + auto *vlay = new QVBoxLayout(info); + vlay->setContentsMargins(0, 0, 0, 0); + vlay->setSpacing(4); + + m_title = new QLabel(info); + QFont tf = m_title->font(); + tf.setPointSize(tf.pointSize() + 5); + tf.setBold(true); + m_title->setFont(tf); + m_title->setWordWrap(true); + vlay->addWidget(m_title); + + m_subtitle = new QLabel(info); + QFont sf = m_subtitle->font(); + sf.setPointSize(sf.pointSize() + 1); + m_subtitle->setFont(sf); + m_subtitle->setWordWrap(true); + vlay->addWidget(m_subtitle); + + m_meta = new QLabel(info); + QPalette mp = m_meta->palette(); + mp.setColor(QPalette::WindowText, QColor(0xaa, 0xaa, 0xaa)); + m_meta->setPalette(mp); + vlay->addWidget(m_meta); + + // Play / Shuffle buttons + auto *btnRow = new QHBoxLayout; + btnRow->setSpacing(8); + btnRow->setContentsMargins(0, 4, 0, 0); + + static const QString btnBase = QStringLiteral( + "QPushButton {" + " padding: 5px 16px;" + " border-radius: 4px;" + " font-weight: bold;" + "}" + "QPushButton:hover { opacity: 0.85; }" + ); + + m_playBtn = new QPushButton(tr("▶ Play"), info); + m_playBtn->setStyleSheet(btnBase + + QStringLiteral("QPushButton { background: #FFB232; color: #000; }" + "QPushButton:pressed { background: #e09e28; }")); + btnRow->addWidget(m_playBtn); + + m_shuffleBtn = new QPushButton(tr("⇄ Shuffle"), info); + m_shuffleBtn->setStyleSheet(btnBase + + QStringLiteral("QPushButton { background: #2a2a2a; color: #FFB232; border: 1px solid #FFB232; }" + "QPushButton:pressed { background: #333; }")); + btnRow->addWidget(m_shuffleBtn); + + btnRow->addStretch(); + vlay->addLayout(btnRow); + + hlay->addWidget(info, 1); + + m_nam = new QNetworkAccessManager(this); + QObject::connect(m_nam, &QNetworkAccessManager::finished, + [this](QNetworkReply *reply) { + reply->deleteLater(); + if (reply->error() != QNetworkReply::NoError) + return; + QPixmap pix; + if (pix.loadFromData(reply->readAll())) + m_art->setPixmap(pix); + }); + } + + QPushButton *playButton() { return m_playBtn; } + QPushButton *shuffleButton() { return m_shuffleBtn; } + + void setAlbum(const QJsonObject &album) + { + m_title->setText(album["title"].toString()); + m_subtitle->setText(album["artist"].toObject()["name"].toString()); + m_meta->setText(buildAlbumMeta(album)); + fetchArt(album["image"].toObject()); + show(); + } + + void setPlaylist(const QJsonObject &playlist) + { + m_title->setText(playlist["name"].toString()); + const QString desc = playlist["description"].toString(); + const QString owner = playlist["owner"].toObject()["name"].toString(); + m_subtitle->setText(desc.isEmpty() ? owner : desc); + m_meta->setText(buildPlaylistMeta(playlist)); + + // Try images300 → images150 → images (API returns mosaic arrays, not image_rectangle) + const QJsonArray imgs300 = playlist["images300"].toArray(); + const QJsonArray imgs150 = playlist["images150"].toArray(); + const QJsonArray imgs = playlist["images"].toArray(); + const QJsonArray &best = !imgs300.isEmpty() ? imgs300 + : !imgs150.isEmpty() ? imgs150 : imgs; + if (!best.isEmpty()) + fetchUrl(best.first().toString()); + else + m_art->setPixmap(QPixmap()); + + show(); + } + +private: + void fetchArt(const QJsonObject &img) + { + QString url = img["large"].toString(); + if (url.isEmpty()) url = img["small"].toString(); + fetchUrl(url); + } + + void fetchUrl(const QString &url) + { + if (url.isEmpty()) { + m_art->setPixmap(QPixmap()); + return; + } + if (url == m_currentArtUrl) + return; + m_currentArtUrl = url; + m_nam->get(QNetworkRequest(QUrl(url))); + } + + static QString formatDuration(int totalSecs) + { + const int h = totalSecs / 3600; + const int m = (totalSecs % 3600) / 60; + if (h > 0) + return QStringLiteral("%1h %2m").arg(h).arg(m); + return QStringLiteral("%1 min").arg(m); + } + + static QString buildAlbumMeta(const QJsonObject &album) + { + QStringList parts; + const QString year = album["release_date_original"].toString().left(4); + if (!year.isEmpty()) parts << year; + const int tracks = album["tracks_count"].toInt(); + if (tracks > 0) parts << QStringLiteral("%1 tracks").arg(tracks); + const int dur = static_cast(album["duration"].toDouble()); + if (dur > 0) parts << formatDuration(dur); + const int bits = album["maximum_bit_depth"].toInt(); + const double rate = album["maximum_sampling_rate"].toDouble(); + if (bits > 0 && rate > 0) { + const QString rateStr = (rate == static_cast(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(" · ")); + } + + 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; + QPushButton *m_playBtn = nullptr; + QPushButton *m_shuffleBtn = nullptr; + QNetworkAccessManager *m_nam = nullptr; + QString m_currentArtUrl; +}; 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;