22 Commits

Author SHA1 Message Date
joren
183a53786f Merge branch 'refactor/uniform-menus'
Some checks failed
Build for Windows / build-windows (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 00:20:49 +02:00
joren
9f178a1cc3 feat: expand toolbar now-playing context menu to full track actions
- Replace bare "Go to Album"/"Go to Artist" with the full unified menu:
  Play next, Add to queue, Add to favorites, Open album/artist with
  names, Add to playlist submenu — all with icons
- Wire up new favTrackRequested/addToPlaylistRequested signals through
  MainWindow
- Pass user playlists to toolbar so the submenu is populated

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 00:17:41 +02:00
joren
d1a2bed593 refactor: unify context menus across all views
- Add icons to all context menus (search panel, genre browser, queue panel)
  matching the track list's established pattern
- Standardize navigation wording: "Open album: X" / "Open artist: X"
  consistently (was "Go to" in search panel, bare "Open Album" in genre browser)
- Genre browser album menu: add "Add to favorites" and "Open artist: X"
  (was missing both)
- Genre browser playlist menu: add icon, lowercase "playlist" for consistency
- Queue panel menu: add list-remove and go-up icons
- Track list: add icon to "Remove from this playlist" action
- Search panel album menu: add icons for all actions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 11:24:50 +02:00
joren
92d48e459e Merge branch 'refactor/color-constants'
Some checks failed
Build for Windows / build-windows (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 11:19:50 +02:00
joren
2139bbb726 Merge branch 'refactor/mainwindow-setup'
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 11:19:50 +02:00
joren
c2e0ff41ac Merge branch 'refactor/playqueue-split'
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 11:19:50 +02:00
joren
28771e12d5 fix: lazy-load genre/playlist views and cap playlist search scrolling
Some checks failed
Build for Windows / build-windows (push) Has been cancelled
- Defer eager-load scrollbar checks to next event loop iteration via
  QTimer::singleShot(0), fixing initial load not filling the viewport
- Playlist search: eagerly fill viewport, then show "Load more playlists…"
  button instead of infinite scroll to avoid loading thousands of results
- Increase search page size from 8 to 25
- Featured/discover playlists keep auto-scroll behavior unchanged

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 11:17:23 +02:00
joren
d1b9cb1210 refactor: centralize hardcoded color values into Colors namespace
Add src/util/colors.hpp with named constants for all QColor values
(brand accents, badge colors, text shades, surface backgrounds) and
replace scattered QColor constructor calls across 7 source files.
Stylesheet string colors are intentionally left inline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 11:01:45 +02:00
joren
5f79170f48 refactor: split MainWindow constructor into focused setup methods
Extract the ~300-line constructor body into setupDocks(), setupScrobbler(),
setupGapless(), setupMpris(), connectBackendSignals(), connectLibrarySignals(),
connectContentSignals(), and connectToolbarSignals(). No behavioral changes;
all signal/slot connections and widget creation remain identical.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 11:00:19 +02:00
joren
dea16676ce refactor: split PlayQueue from header-only into .hpp/.cpp pair
Move all method implementations from playqueue.hpp into a new
playqueue.cpp, keeping only declarations and trivial inline getters
in the header. No logic or behavior changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 10:58:06 +02:00
joren
e9a9077ece Merge branch 'refactor/code-quality'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 10:54:49 +02:00
joren
86b5673e8a refactor: reduce duplication and replace magic stack indices
- Extract parseTrackItem() static helper in TrackListModel to eliminate
  ~45 lines of duplicated JSON parsing between setTracks() and appendTracks()
- Extract notifyFavChanged() helper to deduplicate addFavId/removeFavId loops
- Add StackPage enum to MainContent replacing magic integers 0-5 with
  named constants (PageWelcome, PageTracks, PageAlbumList, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 10:48:21 +02:00
joren
e453f8acf3 feat: add seamless lazy loading for genre and playlist views
Some checks failed
Build for Windows / build-windows (push) Has been cancelled
Introduce paged loading with early prefetch for genre albums/playlists and playlist tracks, while preserving full-data behavior for deep shuffle and playlist play-all actions.
2026-03-31 10:43:36 +02:00
joren
4ebd5ed3f0 fix: detect followed playlists correctly in header
Some checks failed
Build for Windows / build-windows (push) Has been cancelled
Load owner+subscriber playlists, track all playlist IDs for follow-state resolution, and keep audio ring buffer at 32k as requested.
2026-03-31 02:02:31 +02:00
joren
1ad3ba4e69 feat: add album favorites in header and unify browse controls
Some checks failed
Build for Windows / build-windows (push) Has been cancelled
Improve album workflow with in-header favorite toggle, keep favorite state synced from backend, and normalize browse top-bar sizing while increasing audio output buffer headroom to reduce glitches.
2026-03-31 01:49:53 +02:00
joren
cdac82dbef fix: sync dock toggles and make autoplay toolbar-only
Some checks failed
Build for Windows / build-windows (push) Has been cancelled
2026-03-31 01:14:43 +02:00
joren
2aff8fda47 fix: stabilize seek slider and clean backend lint issues
Some checks failed
Build for Windows / build-windows (push) Has been cancelled
2026-03-31 00:57:09 +02:00
joren
5673d6cd30 feat: add deep shuffle in genre browse and tighten toolbar layout
Some checks failed
Build for Windows / build-windows (push) Has been cancelled
2026-03-31 00:37:00 +02:00
joren
96bb21adff feat: add playlist browse/search discovery and follow controls
Some checks failed
Build for Windows / build-windows (push) Has been cancelled
2026-03-31 00:23:56 +02:00
joren
07d6c8a88d perf: reduce queue and autoplay memory footprint
Some checks failed
Build for Windows / build-windows (push) Has been cancelled
2026-03-30 23:24:30 +02:00
joren
cfd91f96b5 feat: add autoplay suggestions when queue runs out
Some checks failed
Build for Windows / build-windows (push) Has been cancelled
2026-03-30 23:14:43 +02:00
joren
2da934f3f6 feat: restore most-popular search and top results badges
Some checks failed
Build for Windows / build-windows (push) Has been cancelled
2026-03-30 22:53:41 +02:00
35 changed files with 2671 additions and 501 deletions

View File

@@ -108,8 +108,10 @@ if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID STREQUAL "Clan
if (UNIX AND NOT APPLE) if (UNIX AND NOT APPLE)
target_compile_options(qobuz-qt PRIVATE target_compile_options(qobuz-qt PRIVATE
-fstack-protector-strong -fstack-protector-strong
-D_FORTIFY_SOURCE=2
-fPIE -fPIE
# _FORTIFY_SOURCE needs optimized builds; in Debug it causes warning spam.
$<$<OR:$<CONFIG:Release>,$<CONFIG:RelWithDebInfo>,$<CONFIG:MinSizeRel>>:-D_FORTIFY_SOURCE=2>
$<$<CONFIG:Debug>:-U_FORTIFY_SOURCE>
) )
target_link_options(qobuz-qt PRIVATE target_link_options(qobuz-qt PRIVATE
-pie -pie

View File

@@ -37,8 +37,15 @@ enum QobuzEvent {
EV_USER_OK = 23, EV_USER_OK = 23,
EV_ARTIST_RELEASES_OK = 24, EV_ARTIST_RELEASES_OK = 24,
EV_DEEP_SHUFFLE_OK = 25, EV_DEEP_SHUFFLE_OK = 25,
EV_MOST_POPULAR_OK = 26,
EV_GENRES_OK = 27, EV_GENRES_OK = 27,
EV_FEATURED_ALBUMS_OK = 28, EV_FEATURED_ALBUMS_OK = 28,
EV_DYNAMIC_SUGGEST_OK = 29,
EV_FEATURED_PLAYLISTS_OK = 30,
EV_DISCOVER_PLAYLISTS_OK = 31,
EV_PLAYLIST_SEARCH_OK = 32,
EV_PLAYLIST_SUBSCRIBED = 33,
EV_PLAYLIST_UNSUBSCRIBED = 34,
}; };
// Callback signature // Callback signature
@@ -55,9 +62,12 @@ void qobuz_backend_get_user(QobuzBackendOpaque *backend);
// Catalog // Catalog
void qobuz_backend_search(QobuzBackendOpaque *backend, const char *query, uint32_t offset, uint32_t limit); void qobuz_backend_search(QobuzBackendOpaque *backend, const char *query, uint32_t offset, uint32_t limit);
void qobuz_backend_most_popular_search(QobuzBackendOpaque *backend, const char *query, uint32_t limit);
void qobuz_backend_get_dynamic_suggestions(QobuzBackendOpaque *backend, const char *listened_track_ids_json, const char *tracks_to_analyze_json, uint32_t limit);
void qobuz_backend_get_album(QobuzBackendOpaque *backend, const char *album_id); void qobuz_backend_get_album(QobuzBackendOpaque *backend, const char *album_id);
void qobuz_backend_get_artist(QobuzBackendOpaque *backend, int64_t artist_id); void qobuz_backend_get_artist(QobuzBackendOpaque *backend, int64_t artist_id);
void qobuz_backend_get_playlist(QobuzBackendOpaque *backend, int64_t playlist_id, uint32_t offset, uint32_t limit); void qobuz_backend_get_playlist(QobuzBackendOpaque *backend, int64_t playlist_id, uint32_t offset, uint32_t limit);
void qobuz_backend_get_playlist_all(QobuzBackendOpaque *backend, int64_t playlist_id);
// Favorites // Favorites
void qobuz_backend_get_fav_tracks(QobuzBackendOpaque *backend, uint32_t offset, uint32_t limit); void qobuz_backend_get_fav_tracks(QobuzBackendOpaque *backend, uint32_t offset, uint32_t limit);
@@ -92,13 +102,18 @@ void qobuz_backend_get_albums_tracks(QobuzBackendOpaque *backend, const char *al
// Browse // Browse
void qobuz_backend_get_genres(QobuzBackendOpaque *backend); void qobuz_backend_get_genres(QobuzBackendOpaque *backend);
void qobuz_backend_get_featured_albums(QobuzBackendOpaque *backend, int64_t genre_id, const char *kind, uint32_t limit, uint32_t offset); void qobuz_backend_get_featured_albums(QobuzBackendOpaque *backend, const char *genre_ids, const char *kind, uint32_t limit, uint32_t offset);
void qobuz_backend_get_featured_playlists(QobuzBackendOpaque *backend, const char *genre_ids, const char *kind, uint32_t limit, uint32_t offset);
void qobuz_backend_discover_playlists(QobuzBackendOpaque *backend, const char *genre_ids, const char *tags, uint32_t limit, uint32_t offset);
void qobuz_backend_search_playlists(QobuzBackendOpaque *backend, const char *query, uint32_t limit, uint32_t offset);
// Playlist management // Playlist management
void qobuz_backend_create_playlist(QobuzBackendOpaque *backend, const char *name); void qobuz_backend_create_playlist(QobuzBackendOpaque *backend, const char *name);
void qobuz_backend_delete_playlist(QobuzBackendOpaque *backend, int64_t playlist_id); 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_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); void qobuz_backend_delete_track_from_playlist(QobuzBackendOpaque *backend, int64_t playlist_id, int64_t playlist_track_id);
void qobuz_backend_subscribe_playlist(QobuzBackendOpaque *backend, int64_t playlist_id);
void qobuz_backend_unsubscribe_playlist(QobuzBackendOpaque *backend, int64_t playlist_id);
// Favorites modification // Favorites modification
void qobuz_backend_add_fav_track(QobuzBackendOpaque *backend, int64_t track_id); void qobuz_backend_add_fav_track(QobuzBackendOpaque *backend, int64_t track_id);

View File

@@ -416,7 +416,7 @@ impl QobuzClient {
pub async fn get_featured_albums( pub async fn get_featured_albums(
&self, &self,
genre_id: i64, genre_ids: &str,
kind: &str, kind: &str,
limit: u32, limit: u32,
offset: u32, offset: u32,
@@ -425,7 +425,64 @@ impl QobuzClient {
.get_request("album/getFeatured") .get_request("album/getFeatured")
.query(&[ .query(&[
("type", kind.to_string()), ("type", kind.to_string()),
("genre_id", genre_id.to_string()), ("genre_id", genre_ids.to_string()),
("limit", limit.to_string()),
("offset", offset.to_string()),
])
.send()
.await?;
Self::check_response(resp).await
}
pub async fn get_featured_playlists(
&self,
genre_ids: &str,
kind: &str,
limit: u32,
offset: u32,
) -> Result<Value> {
let resp = self
.get_request("playlist/getFeatured")
.query(&[
("type", kind.to_string()),
("genre_ids", genre_ids.to_string()),
("limit", limit.to_string()),
("offset", offset.to_string()),
])
.send()
.await?;
Self::check_response(resp).await
}
pub async fn discover_playlists(
&self,
genre_ids: &str,
tags: &str,
limit: u32,
offset: u32,
) -> Result<Value> {
let mut query = vec![
("genre_ids", genre_ids.to_string()),
("limit", limit.to_string()),
("offset", offset.to_string()),
];
if !tags.is_empty() {
query.push(("tags", tags.to_string()));
}
let resp = self
.get_request("discover/playlists")
.query(&query)
.send()
.await?;
Self::check_response(resp).await
}
pub async fn search_playlists(&self, query: &str, limit: u32, offset: u32) -> Result<Value> {
let resp = self
.get_request("playlist/search")
.query(&[
("query", query.to_string()),
("limit", limit.to_string()), ("limit", limit.to_string()),
("offset", offset.to_string()), ("offset", offset.to_string()),
]) ])
@@ -453,6 +510,42 @@ impl QobuzClient {
}) })
} }
pub async fn get_most_popular(
&self,
query: &str,
offset: u32,
limit: u32,
) -> Result<Value> {
let resp = self
.get_request("most-popular/get")
.query(&[
("query", query.to_string()),
("offset", offset.to_string()),
("limit", limit.to_string()),
])
.send()
.await?;
Self::check_response(resp).await
}
pub async fn get_dynamic_suggestions(
&self,
limit: u32,
listened_tracks_ids: Value,
tracks_to_analyze: Value,
) -> Result<Value> {
let resp = self
.post_request("dynamic/suggest")
.json(&serde_json::json!({
"limit": limit,
"listened_tracks_ids": listened_tracks_ids,
"track_to_analysed": tracks_to_analyze,
}))
.send()
.await?;
Self::check_response(resp).await
}
async fn search_tracks( async fn search_tracks(
&self, &self,
query: &str, query: &str,
@@ -516,6 +609,7 @@ impl QobuzClient {
let resp = self let resp = self
.get_request("playlist/getUserPlaylists") .get_request("playlist/getUserPlaylists")
.query(&[ .query(&[
("filter", "owner,subscriber"),
("offset", &offset.to_string()), ("offset", &offset.to_string()),
("limit", &limit.to_string()), ("limit", &limit.to_string()),
]) ])
@@ -545,6 +639,51 @@ impl QobuzClient {
Ok(serde_json::from_value(body)?) Ok(serde_json::from_value(body)?)
} }
pub async fn get_playlist_all(&self, playlist_id: i64) -> Result<PlaylistDto> {
const PAGE_LIMIT: u32 = 500;
let mut playlist = self.get_playlist(playlist_id, 0, PAGE_LIMIT).await?;
let mut all_items = playlist
.tracks
.as_ref()
.and_then(|t| t.items.clone())
.unwrap_or_default();
let mut total = playlist
.tracks
.as_ref()
.and_then(|t| t.total)
.unwrap_or(all_items.len() as i32);
if total < all_items.len() as i32 {
total = all_items.len() as i32;
}
let mut offset = all_items.len() as u32;
while (offset as i32) < total {
let page = self.get_playlist(playlist_id, offset, PAGE_LIMIT).await?;
let mut page_items = page
.tracks
.as_ref()
.and_then(|t| t.items.clone())
.unwrap_or_default();
if page_items.is_empty() {
break;
}
all_items.append(&mut page_items);
offset = all_items.len() as u32;
}
if let Some(tracks) = playlist.tracks.as_mut() {
tracks.items = Some(all_items);
tracks.total = Some(total);
tracks.offset = Some(0);
tracks.limit = Some(PAGE_LIMIT as i32);
}
Ok(playlist)
}
/// Fetch all favorite IDs (tracks, albums, artists) in one call. /// Fetch all favorite IDs (tracks, albums, artists) in one call.
async fn get_fav_ids(&self) -> Result<FavIdsDto> { async fn get_fav_ids(&self) -> Result<FavIdsDto> {
let resp = self let resp = self
@@ -743,6 +882,26 @@ impl QobuzClient {
Ok(()) Ok(())
} }
pub async fn subscribe_playlist(&self, playlist_id: i64) -> Result<()> {
let resp = self
.get_request("playlist/subscribe")
.query(&[("playlist_id", playlist_id.to_string())])
.send()
.await?;
Self::check_response(resp).await?;
Ok(())
}
pub async fn unsubscribe_playlist(&self, playlist_id: i64) -> Result<()> {
let resp = self
.get_request("playlist/unsubscribe")
.query(&[("playlist_id", playlist_id.to_string())])
.send()
.await?;
Self::check_response(resp).await?;
Ok(())
}
pub async fn add_fav_track(&self, track_id: i64) -> Result<()> { pub async fn add_fav_track(&self, track_id: i64) -> Result<()> {
let resp = self let resp = self
.get_request("favorite/create") .get_request("favorite/create")

View File

@@ -1,4 +1,5 @@
//! qobuz-backend: C-ABI library consumed by the Qt frontend. //! qobuz-backend: C-ABI library consumed by the Qt frontend.
#![allow(clippy::missing_safety_doc)]
mod api; mod api;
mod player; mod player;
@@ -45,8 +46,15 @@ pub const EV_TRACK_URL_ERR: c_int = 18;
pub const EV_GENERIC_ERR: c_int = 19; pub const EV_GENERIC_ERR: c_int = 19;
pub const EV_ARTIST_RELEASES_OK: c_int = 24; pub const EV_ARTIST_RELEASES_OK: c_int = 24;
pub const EV_DEEP_SHUFFLE_OK: c_int = 25; pub const EV_DEEP_SHUFFLE_OK: c_int = 25;
pub const EV_MOST_POPULAR_OK: c_int = 26;
pub const EV_GENRES_OK: c_int = 27; pub const EV_GENRES_OK: c_int = 27;
pub const EV_FEATURED_ALBUMS_OK: c_int = 28; pub const EV_FEATURED_ALBUMS_OK: c_int = 28;
pub const EV_DYNAMIC_SUGGEST_OK: c_int = 29;
pub const EV_FEATURED_PLAYLISTS_OK: c_int = 30;
pub const EV_DISCOVER_PLAYLISTS_OK: c_int = 31;
pub const EV_PLAYLIST_SEARCH_OK: c_int = 32;
pub const EV_PLAYLIST_SUBSCRIBED: c_int = 33;
pub const EV_PLAYLIST_UNSUBSCRIBED: c_int = 34;
// ---------- Callback ---------- // ---------- Callback ----------
@@ -203,6 +211,89 @@ pub unsafe extern "C" fn qobuz_backend_search(
}); });
} }
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_most_popular_search(
ptr: *mut Backend,
query: *const c_char,
limit: u32,
) {
let inner = &(*ptr).0;
let query = CStr::from_ptr(query).to_string_lossy().into_owned();
let client = inner.client.clone();
let cb = inner.cb;
let ud = inner.ud;
spawn(inner, async move {
let result = client
.lock()
.await
.get_most_popular(&query, 0, limit)
.await;
match result {
Ok(r) => call_cb(
cb,
ud,
EV_MOST_POPULAR_OK,
&serde_json::to_string(&r).unwrap_or_default(),
),
Err(e) => call_cb(cb, ud, EV_SEARCH_ERR, &err_json(&e.to_string())),
}
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_dynamic_suggestions(
ptr: *mut Backend,
listened_track_ids_json: *const c_char,
tracks_to_analyze_json: *const c_char,
limit: u32,
) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb;
let ud = inner.ud;
let listened_str = CStr::from_ptr(listened_track_ids_json)
.to_string_lossy()
.into_owned();
let analyze_str = CStr::from_ptr(tracks_to_analyze_json)
.to_string_lossy()
.into_owned();
let listened: serde_json::Value = match serde_json::from_str(&listened_str) {
Ok(v) => v,
Err(e) => {
call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string()));
return;
}
};
let to_analyze: serde_json::Value = match serde_json::from_str(&analyze_str) {
Ok(v) => v,
Err(e) => {
call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string()));
return;
}
};
spawn(inner, async move {
let result = client
.lock()
.await
.get_dynamic_suggestions(limit, listened, to_analyze)
.await;
match result {
Ok(r) => call_cb(
cb,
ud,
EV_DYNAMIC_SUGGEST_OK,
&serde_json::to_string(&r).unwrap_or_default(),
),
Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())),
}
});
}
// ---------- Album ---------- // ---------- Album ----------
#[no_mangle] #[no_mangle]
@@ -396,7 +487,7 @@ pub unsafe extern "C" fn qobuz_backend_get_genres(ptr: *mut Backend) {
#[no_mangle] #[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_featured_albums( pub unsafe extern "C" fn qobuz_backend_get_featured_albums(
ptr: *mut Backend, ptr: *mut Backend,
genre_id: i64, genre_ids: *const c_char,
kind: *const c_char, kind: *const c_char,
limit: u32, limit: u32,
offset: u32, offset: u32,
@@ -405,13 +496,14 @@ pub unsafe extern "C" fn qobuz_backend_get_featured_albums(
let client = inner.client.clone(); let client = inner.client.clone();
let cb = inner.cb; let cb = inner.cb;
let ud = inner.ud; let ud = inner.ud;
let genre_ids_str = CStr::from_ptr(genre_ids).to_string_lossy().into_owned();
let kind_str = CStr::from_ptr(kind).to_string_lossy().into_owned(); let kind_str = CStr::from_ptr(kind).to_string_lossy().into_owned();
spawn(inner, async move { spawn(inner, async move {
let result = client let result = client
.lock() .lock()
.await .await
.get_featured_albums(genre_id, &kind_str, limit, offset) .get_featured_albums(&genre_ids_str, &kind_str, limit, offset)
.await; .await;
match result { match result {
Ok(r) => { Ok(r) => {
@@ -421,7 +513,9 @@ pub unsafe extern "C" fn qobuz_backend_get_featured_albums(
"items": items, "items": items,
"total": total, "total": total,
"type": kind_str, "type": kind_str,
"genre_id": genre_id, "genre_ids": genre_ids_str,
"offset": offset,
"limit": limit,
}); });
call_cb( call_cb(
cb, cb,
@@ -435,6 +529,138 @@ pub unsafe extern "C" fn qobuz_backend_get_featured_albums(
}); });
} }
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_featured_playlists(
ptr: *mut Backend,
genre_ids: *const c_char,
kind: *const c_char,
limit: u32,
offset: u32,
) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb;
let ud = inner.ud;
let kind_str = CStr::from_ptr(kind).to_string_lossy().into_owned();
let genre_ids_str = CStr::from_ptr(genre_ids).to_string_lossy().into_owned();
spawn(inner, async move {
let result = client
.lock()
.await
.get_featured_playlists(&genre_ids_str, &kind_str, limit, offset)
.await;
match result {
Ok(r) => {
let items = r["playlists"]["items"].clone();
let total = r["playlists"]["total"].as_i64().unwrap_or(0);
let out = serde_json::json!({
"items": items,
"total": total,
"type": kind_str,
"genre_ids": genre_ids_str,
"offset": offset,
"limit": limit,
});
call_cb(
cb,
ud,
EV_FEATURED_PLAYLISTS_OK,
&serde_json::to_string(&out).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_discover_playlists(
ptr: *mut Backend,
genre_ids: *const c_char,
tags: *const c_char,
limit: u32,
offset: u32,
) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb;
let ud = inner.ud;
let genre_ids_str = CStr::from_ptr(genre_ids).to_string_lossy().into_owned();
let tags_str = CStr::from_ptr(tags).to_string_lossy().into_owned();
spawn(inner, async move {
let result = client
.lock()
.await
.discover_playlists(&genre_ids_str, &tags_str, limit, offset)
.await;
match result {
Ok(r) => {
let items = r["items"].clone();
let total = r["total"].as_i64().unwrap_or(0);
let out = serde_json::json!({
"items": items,
"total": total,
"genre_ids": genre_ids_str,
"tags": tags_str,
"offset": offset,
"limit": limit,
});
call_cb(
cb,
ud,
EV_DISCOVER_PLAYLISTS_OK,
&serde_json::to_string(&out).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_search_playlists(
ptr: *mut Backend,
query: *const c_char,
limit: u32,
offset: u32,
) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb;
let ud = inner.ud;
let query_str = CStr::from_ptr(query).to_string_lossy().into_owned();
spawn(inner, async move {
let result = client
.lock()
.await
.search_playlists(&query_str, limit, offset)
.await;
match result {
Ok(r) => {
let items = r["playlists"]["items"].clone();
let total = r["playlists"]["total"].as_i64().unwrap_or(0);
let out = serde_json::json!({
"items": items,
"total": total,
"query": query_str,
"offset": offset,
"limit": limit,
});
call_cb(
cb,
ud,
EV_PLAYLIST_SEARCH_OK,
&serde_json::to_string(&out).unwrap_or_default(),
);
}
Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())),
}
});
}
// ---------- Playlist ---------- // ---------- Playlist ----------
#[no_mangle] #[no_mangle]
@@ -466,6 +692,28 @@ pub unsafe extern "C" fn qobuz_backend_get_playlist(
}); });
} }
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_playlist_all(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 {
let result = client.lock().await.get_playlist_all(playlist_id).await;
let (ev, json) = match result {
Ok(r) => {
let mut v = serde_json::to_value(&r).unwrap_or_default();
if let serde_json::Value::Object(ref mut obj) = v {
obj.insert("full_load".to_string(), serde_json::Value::Bool(true));
}
(EV_PLAYLIST_OK, serde_json::to_string(&v).unwrap_or_default())
}
Err(e) => (EV_PLAYLIST_ERR, err_json(&e.to_string())),
};
call_cb(cb, ud, ev, &json);
});
}
// ---------- Favorites ---------- // ---------- Favorites ----------
#[no_mangle] #[no_mangle]
@@ -1044,3 +1292,41 @@ pub unsafe extern "C" fn qobuz_backend_delete_track_from_playlist(
} }
}); });
} }
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_subscribe_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.subscribe_playlist(playlist_id).await {
Ok(()) => call_cb(
cb,
ud,
EV_PLAYLIST_SUBSCRIBED,
&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_unsubscribe_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.unsubscribe_playlist(playlist_id).await {
Ok(()) => call_cb(
cb,
ud,
EV_PLAYLIST_UNSUBSCRIBED,
&serde_json::json!({"playlist_id": playlist_id}).to_string(),
),
Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())),
}
});
}

View File

@@ -157,7 +157,7 @@ impl MediaSource for SegmentStreamSource {
fn http_client() -> &'static reqwest::blocking::Client { fn http_client() -> &'static reqwest::blocking::Client {
static CLIENT: OnceLock<reqwest::blocking::Client> = OnceLock::new(); static CLIENT: OnceLock<reqwest::blocking::Client> = OnceLock::new();
CLIENT.get_or_init(|| reqwest::blocking::Client::new()) CLIENT.get_or_init(reqwest::blocking::Client::new)
} }
fn fetch_segment(url: &str, cancel: &Arc<AtomicBool>) -> Option<Vec<u8>> { fn fetch_segment(url: &str, cancel: &Arc<AtomicBool>) -> Option<Vec<u8>> {
@@ -260,8 +260,10 @@ fn decrypt_and_extract_frames(data: &mut [u8], key: Option<&[u8; 16]>) -> Vec<u8
break; break;
} }
if &data[pos + 4..pos + 8] == b"uuid" && box_size >= 36 { if data[pos + 4..pos + 8] == *b"uuid"
if &data[pos + 8..pos + 24] == QBZ1_UUID { && box_size >= 36
&& data[pos + 8..pos + 24] == QBZ1_UUID
{
let body = pos + 24; let body = pos + 24;
if body + 12 > data.len() { if body + 12 > data.len() {
pos += box_size; pos += box_size;
@@ -284,18 +286,19 @@ fn decrypt_and_extract_frames(data: &mut [u8], key: Option<&[u8; 16]>) -> Vec<u8
let end = offset + size; let end = offset + size;
if end <= data.len() { if end <= data.len() {
if enc && key.is_some() { if enc {
if let Some(k) = key {
let mut iv = [0u8; 16]; let mut iv = [0u8; 16];
iv[..8].copy_from_slice(&data[e + 8..e + 16]); iv[..8].copy_from_slice(&data[e + 8..e + 16]);
Ctr128BE::<Aes128>::new(key.unwrap().into(), (&iv).into()) Ctr128BE::<Aes128>::new(k.into(), (&iv).into())
.apply_keystream(&mut data[offset..end]); .apply_keystream(&mut data[offset..end]);
} }
}
frames.extend_from_slice(&data[offset..end]); frames.extend_from_slice(&data[offset..end]);
} }
offset += size; offset += size;
} }
} }
}
pos += box_size; pos += box_size;
} }
frames frames
@@ -845,7 +848,7 @@ impl Seek for HttpStreamSource {
.send() .send()
{ {
Ok(r) => r, Ok(r) => r,
Err(e) => return Err(io::Error::new(io::ErrorKind::Other, e.to_string())), Err(e) => return Err(io::Error::other(e.to_string())),
}; };
if resp.status() == reqwest::StatusCode::PARTIAL_CONTENT { if resp.status() == reqwest::StatusCode::PARTIAL_CONTENT {
@@ -881,10 +884,7 @@ impl Seek for HttpStreamSource {
self.pos = self.reader_pos; self.pos = self.reader_pos;
Ok(self.pos) Ok(self.pos)
} else { } else {
Err(io::Error::new( Err(io::Error::other(format!("HTTP Error {}", resp.status())))
io::ErrorKind::Other,
format!("HTTP Error {}", resp.status()),
))
} }
} }
} }

View File

@@ -90,6 +90,7 @@ impl AudioOutput {
Ok(()) Ok(())
} }
#[allow(dead_code)]
pub fn flush(&self) { pub fn flush(&self) {
// Wait until the ring buffer is fully emptied by cpal // Wait until the ring buffer is fully emptied by cpal
while !self._ring.is_empty() { while !self._ring.is_empty() {

View File

@@ -6,8 +6,9 @@ target_sources(qobuz-qt PRIVATE
mainwindow.hpp mainwindow.hpp
mainwindow.cpp mainwindow.cpp
# Queue (header-only) # Queue
playqueue.hpp playqueue.hpp
playqueue.cpp
# Backend (Qt wrapper around Rust FFI) # Backend (Qt wrapper around Rust FFI)
backend/qobuzbackend.hpp backend/qobuzbackend.hpp

View File

@@ -51,6 +51,22 @@ void QobuzBackend::search(const QString &query, quint32 offset, quint32 limit)
qobuz_backend_search(m_backend, query.toUtf8().constData(), offset, limit); qobuz_backend_search(m_backend, query.toUtf8().constData(), offset, limit);
} }
void QobuzBackend::mostPopularSearch(const QString &query, quint32 limit)
{
qobuz_backend_most_popular_search(m_backend, query.toUtf8().constData(), limit);
}
void QobuzBackend::getDynamicSuggestions(const QJsonArray &listenedTrackIds, const QJsonArray &tracksToAnalyze, quint32 limit)
{
const QByteArray listened = QJsonDocument(listenedTrackIds).toJson(QJsonDocument::Compact);
const QByteArray analyze = QJsonDocument(tracksToAnalyze).toJson(QJsonDocument::Compact);
qobuz_backend_get_dynamic_suggestions(
m_backend,
listened.constData(),
analyze.constData(),
limit);
}
void QobuzBackend::getAlbum(const QString &albumId) void QobuzBackend::getAlbum(const QString &albumId)
{ {
qobuz_backend_get_album(m_backend, albumId.toUtf8().constData()); qobuz_backend_get_album(m_backend, albumId.toUtf8().constData());
@@ -79,14 +95,39 @@ void QobuzBackend::getPlaylist(qint64 playlistId, quint32 offset, quint32 limit)
qobuz_backend_get_playlist(m_backend, playlistId, offset, limit); qobuz_backend_get_playlist(m_backend, playlistId, offset, limit);
} }
void QobuzBackend::getPlaylistAll(qint64 playlistId)
{
qobuz_backend_get_playlist_all(m_backend, playlistId);
}
void QobuzBackend::getGenres() void QobuzBackend::getGenres()
{ {
qobuz_backend_get_genres(m_backend); qobuz_backend_get_genres(m_backend);
} }
void QobuzBackend::getFeaturedAlbums(qint64 genreId, const QString &kind, quint32 limit, quint32 offset) void QobuzBackend::getFeaturedAlbums(const QString &genreIds, const QString &kind, quint32 limit, quint32 offset)
{ {
qobuz_backend_get_featured_albums(m_backend, genreId, kind.toUtf8().constData(), limit, offset); qobuz_backend_get_featured_albums(m_backend, genreIds.toUtf8().constData(), kind.toUtf8().constData(), limit, offset);
}
void QobuzBackend::getFeaturedPlaylists(const QString &genreIds, const QString &kind, quint32 limit, quint32 offset)
{
qobuz_backend_get_featured_playlists(m_backend, genreIds.toUtf8().constData(), kind.toUtf8().constData(), limit, offset);
}
void QobuzBackend::discoverPlaylists(const QString &genreIds, const QString &tags, quint32 limit, quint32 offset)
{
qobuz_backend_discover_playlists(
m_backend,
genreIds.toUtf8().constData(),
tags.toUtf8().constData(),
limit,
offset);
}
void QobuzBackend::searchPlaylists(const QString &query, quint32 limit, quint32 offset)
{
qobuz_backend_search_playlists(m_backend, query.toUtf8().constData(), limit, offset);
} }
// ---- favorites ---- // ---- favorites ----
@@ -150,6 +191,16 @@ void QobuzBackend::deleteTrackFromPlaylist(qint64 playlistId, qint64 playlistTra
qobuz_backend_delete_track_from_playlist(m_backend, playlistId, playlistTrackId); qobuz_backend_delete_track_from_playlist(m_backend, playlistId, playlistTrackId);
} }
void QobuzBackend::subscribePlaylist(qint64 playlistId)
{
qobuz_backend_subscribe_playlist(m_backend, playlistId);
}
void QobuzBackend::unsubscribePlaylist(qint64 playlistId)
{
qobuz_backend_unsubscribe_playlist(m_backend, playlistId);
}
// ---- fav modification ---- // ---- fav modification ----
void QobuzBackend::addFavTrack(qint64 trackId) void QobuzBackend::addFavTrack(qint64 trackId)
@@ -251,6 +302,9 @@ void QobuzBackend::onEvent(int eventType, const QString &json)
case EV_SEARCH_OK: case EV_SEARCH_OK:
emit searchResult(obj); emit searchResult(obj);
break; break;
case EV_MOST_POPULAR_OK:
emit mostPopularResult(obj);
break;
case EV_SEARCH_ERR: case EV_SEARCH_ERR:
emit error(obj["error"].toString()); emit error(obj["error"].toString());
break; break;
@@ -263,7 +317,7 @@ void QobuzBackend::onEvent(int eventType, const QString &json)
case EV_ARTIST_OK: case EV_ARTIST_OK:
emit artistLoaded(obj); emit artistLoaded(obj);
break; break;
case 24: // EV_ARTIST_RELEASES_OK case EV_ARTIST_RELEASES_OK:
emit artistReleasesLoaded( emit artistReleasesLoaded(
obj["release_type"].toString(), obj["release_type"].toString(),
obj["items"].toArray(), obj["items"].toArray(),
@@ -271,15 +325,27 @@ void QobuzBackend::onEvent(int eventType, const QString &json)
obj["offset"].toInt() obj["offset"].toInt()
); );
break; break;
case 25: // EV_DEEP_SHUFFLE_OK case EV_DEEP_SHUFFLE_OK:
emit deepShuffleTracksLoaded(obj["tracks"].toArray()); emit deepShuffleTracksLoaded(obj["tracks"].toArray());
break; break;
case 27: // EV_GENRES_OK case EV_DYNAMIC_SUGGEST_OK:
emit dynamicSuggestionsLoaded(obj);
break;
case EV_GENRES_OK:
emit genresLoaded(obj); emit genresLoaded(obj);
break; break;
case 28: // EV_FEATURED_ALBUMS_OK case EV_FEATURED_ALBUMS_OK:
emit featuredAlbumsLoaded(obj); emit featuredAlbumsLoaded(obj);
break; break;
case EV_FEATURED_PLAYLISTS_OK:
emit featuredPlaylistsLoaded(obj);
break;
case EV_DISCOVER_PLAYLISTS_OK:
emit discoverPlaylistsLoaded(obj);
break;
case EV_PLAYLIST_SEARCH_OK:
emit playlistSearchLoaded(obj);
break;
case EV_ARTIST_ERR: case EV_ARTIST_ERR:
emit error(obj["error"].toString()); emit error(obj["error"].toString());
break; break;
@@ -307,15 +373,21 @@ void QobuzBackend::onEvent(int eventType, const QString &json)
case EV_STATE_CHANGED: case EV_STATE_CHANGED:
emit stateChanged(obj["state"].toString()); emit stateChanged(obj["state"].toString());
break; break;
case 20: // EV_PLAYLIST_CREATED case EV_PLAYLIST_CREATED:
emit playlistCreated(obj); emit playlistCreated(obj);
break; break;
case 21: // EV_PLAYLIST_DELETED case EV_PLAYLIST_DELETED:
emit playlistDeleted(obj); emit playlistDeleted(obj);
break; break;
case 22: // EV_PLAYLIST_TRACK_ADDED case EV_PLAYLIST_TRACK_ADDED:
emit playlistTrackAdded(static_cast<qint64>(obj["playlist_id"].toDouble())); emit playlistTrackAdded(static_cast<qint64>(obj["playlist_id"].toDouble()));
break; break;
case EV_PLAYLIST_SUBSCRIBED:
emit playlistSubscribed(static_cast<qint64>(obj["playlist_id"].toDouble()));
break;
case EV_PLAYLIST_UNSUBSCRIBED:
emit playlistUnsubscribed(static_cast<qint64>(obj["playlist_id"].toDouble()));
break;
case EV_USER_OK: case EV_USER_OK:
emit userLoaded(obj); emit userLoaded(obj);
break; break;

View File

@@ -28,19 +28,25 @@ public:
// --- catalog --- // --- catalog ---
void search(const QString &query, quint32 offset = 0, quint32 limit = 20); void search(const QString &query, quint32 offset = 0, quint32 limit = 20);
void mostPopularSearch(const QString &query, quint32 limit = 8);
void getDynamicSuggestions(const QJsonArray &listenedTrackIds, const QJsonArray &tracksToAnalyze, quint32 limit = 50);
void getAlbum(const QString &albumId); void getAlbum(const QString &albumId);
void getArtist(qint64 artistId); void getArtist(qint64 artistId);
void getArtistReleases(qint64 artistId, const QString &releaseType, quint32 limit = 50, quint32 offset = 0); void getArtistReleases(qint64 artistId, const QString &releaseType, quint32 limit = 50, quint32 offset = 0);
void getAlbumsTracks(const QStringList &albumIds); void getAlbumsTracks(const QStringList &albumIds);
void getPlaylist(qint64 playlistId, quint32 offset = 0, quint32 limit = 500); void getPlaylist(qint64 playlistId, quint32 offset = 0, quint32 limit = 500);
void getPlaylistAll(qint64 playlistId);
void getGenres(); void getGenres();
void getFeaturedAlbums(qint64 genreId, const QString &kind, quint32 limit = 50, quint32 offset = 0); void getFeaturedAlbums(const QString &genreIds, const QString &kind, quint32 limit = 50, quint32 offset = 0);
void getFeaturedPlaylists(const QString &genreIds, const QString &kind, quint32 limit = 25, quint32 offset = 0);
void discoverPlaylists(const QString &genreIds, const QString &tags = QString(), quint32 limit = 25, quint32 offset = 0);
void searchPlaylists(const QString &query, quint32 limit = 8, quint32 offset = 0);
// --- favorites --- // --- favorites ---
void getFavTracks(quint32 offset = 0, quint32 limit = 500); void getFavTracks(quint32 offset = 0, quint32 limit = 500);
void getFavAlbums(quint32 offset = 0, quint32 limit = 200); void getFavAlbums(quint32 offset = 0, quint32 limit = 200);
void getFavArtists(quint32 offset = 0, quint32 limit = 200); void getFavArtists(quint32 offset = 0, quint32 limit = 200);
void getUserPlaylists(quint32 offset = 0, quint32 limit = 200); void getUserPlaylists(quint32 offset = 0, quint32 limit = 350);
// --- playback options --- // --- playback options ---
void setReplayGain(bool enabled); void setReplayGain(bool enabled);
@@ -52,6 +58,8 @@ public:
void deletePlaylist(qint64 playlistId); void deletePlaylist(qint64 playlistId);
void addTrackToPlaylist(qint64 playlistId, qint64 trackId); void addTrackToPlaylist(qint64 playlistId, qint64 trackId);
void deleteTrackFromPlaylist(qint64 playlistId, qint64 playlistTrackId); void deleteTrackFromPlaylist(qint64 playlistId, qint64 playlistTrackId);
void subscribePlaylist(qint64 playlistId);
void unsubscribePlaylist(qint64 playlistId);
// --- fav modification --- // --- fav modification ---
void addFavTrack(qint64 trackId); void addFavTrack(qint64 trackId);
@@ -83,16 +91,23 @@ signals:
// catalog // catalog
void searchResult(const QJsonObject &result); void searchResult(const QJsonObject &result);
void mostPopularResult(const QJsonObject &result);
void albumLoaded(const QJsonObject &album); void albumLoaded(const QJsonObject &album);
void artistLoaded(const QJsonObject &artist); void artistLoaded(const QJsonObject &artist);
void artistReleasesLoaded(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset); void artistReleasesLoaded(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset);
void deepShuffleTracksLoaded(const QJsonArray &tracks); void deepShuffleTracksLoaded(const QJsonArray &tracks);
void dynamicSuggestionsLoaded(const QJsonObject &result);
void genresLoaded(const QJsonObject &result); void genresLoaded(const QJsonObject &result);
void featuredAlbumsLoaded(const QJsonObject &result); void featuredAlbumsLoaded(const QJsonObject &result);
void featuredPlaylistsLoaded(const QJsonObject &result);
void discoverPlaylistsLoaded(const QJsonObject &result);
void playlistSearchLoaded(const QJsonObject &result);
void playlistLoaded(const QJsonObject &playlist); void playlistLoaded(const QJsonObject &playlist);
void playlistCreated(const QJsonObject &playlist); void playlistCreated(const QJsonObject &playlist);
void playlistDeleted(const QJsonObject &result); void playlistDeleted(const QJsonObject &result);
void playlistTrackAdded(qint64 playlistId); void playlistTrackAdded(qint64 playlistId);
void playlistSubscribed(qint64 playlistId);
void playlistUnsubscribed(qint64 playlistId);
// favorites // favorites
void favTracksLoaded(const QJsonObject &result); void favTracksLoaded(const QJsonObject &result);

View File

@@ -21,6 +21,7 @@ enum NodeType {
NodeFavArtists, NodeFavArtists,
NodePlaylist, NodePlaylist,
NodeBrowseGenres, NodeBrowseGenres,
NodeBrowsePlaylists,
}; };
Library::Library(QobuzBackend *backend, QWidget *parent) Library::Library(QobuzBackend *backend, QWidget *parent)
@@ -79,6 +80,9 @@ void Library::buildStaticNodes()
auto *genresItem = new QTreeWidgetItem(m_browseNode, QStringList{tr("Genres")}); auto *genresItem = new QTreeWidgetItem(m_browseNode, QStringList{tr("Genres")});
genresItem->setData(0, TypeRole, NodeBrowseGenres); genresItem->setData(0, TypeRole, NodeBrowseGenres);
auto *playlistsItem = new QTreeWidgetItem(m_browseNode, QStringList{tr("Playlists")});
playlistsItem->setData(0, TypeRole, NodeBrowsePlaylists);
} }
void Library::refresh() void Library::refresh()
@@ -95,6 +99,7 @@ void Library::onUserPlaylistsLoaded(const QJsonObject &result)
while (m_playlistsNode->childCount() > 0) while (m_playlistsNode->childCount() > 0)
delete m_playlistsNode->takeChild(0); delete m_playlistsNode->takeChild(0);
QSet<qint64> allPlaylistIds;
QVector<QPair<qint64, QString>> editablePlaylists; QVector<QPair<qint64, QString>> editablePlaylists;
const qint64 myUserId = AppSettings::instance().userId(); const qint64 myUserId = AppSettings::instance().userId();
const QJsonArray items = result["items"].toArray(); const QJsonArray items = result["items"].toArray();
@@ -105,6 +110,9 @@ void Library::onUserPlaylistsLoaded(const QJsonObject &result)
const qint64 ownId = static_cast<qint64>(pl["owner"].toObject()["id"].toDouble()); const qint64 ownId = static_cast<qint64>(pl["owner"].toObject()["id"].toDouble());
const bool isOwner = (myUserId > 0 && ownId == myUserId); const bool isOwner = (myUserId > 0 && ownId == myUserId);
if (id > 0)
allPlaylistIds.insert(id);
auto *item = new QTreeWidgetItem(m_playlistsNode, QStringList{name}); auto *item = new QTreeWidgetItem(m_playlistsNode, QStringList{name});
item->setData(0, TypeRole, NodePlaylist); item->setData(0, TypeRole, NodePlaylist);
item->setData(0, IdRole, id); item->setData(0, IdRole, id);
@@ -116,6 +124,7 @@ void Library::onUserPlaylistsLoaded(const QJsonObject &result)
editablePlaylists.append({id, name}); editablePlaylists.append({id, name});
} }
emit userPlaylistIdsChanged(allPlaylistIds);
emit userPlaylistsChanged(editablePlaylists); emit userPlaylistsChanged(editablePlaylists);
} }
@@ -176,6 +185,7 @@ void Library::onItemClicked(QTreeWidgetItem *item, int)
case NodeFavAlbums: emit favAlbumsRequested(); break; case NodeFavAlbums: emit favAlbumsRequested(); break;
case NodeFavArtists: emit favArtistsRequested(); break; case NodeFavArtists: emit favArtistsRequested(); break;
case NodeBrowseGenres: emit browseGenresRequested(); break; case NodeBrowseGenres: emit browseGenresRequested(); break;
case NodeBrowsePlaylists: emit browsePlaylistsRequested(); break;
case NodePlaylist: { case NodePlaylist: {
const qint64 id = item->data(0, IdRole).toLongLong(); const qint64 id = item->data(0, IdRole).toLongLong();
const QString name = item->data(0, NameRole).toString(); const QString name = item->data(0, NameRole).toString();

View File

@@ -7,6 +7,7 @@
#include <QVector> #include <QVector>
#include <QPair> #include <QPair>
#include <QString> #include <QString>
#include <QSet>
namespace List namespace List
{ {
@@ -27,9 +28,12 @@ namespace List
void favAlbumsRequested(); void favAlbumsRequested();
void favArtistsRequested(); void favArtistsRequested();
void browseGenresRequested(); void browseGenresRequested();
void browsePlaylistsRequested();
void playlistRequested(qint64 playlistId, const QString &name); void playlistRequested(qint64 playlistId, const QString &name);
/// Emitted after playlists are loaded so others can cache the list. /// Emitted after playlists are loaded so others can cache the list.
void userPlaylistsChanged(const QVector<QPair<qint64, QString>> &playlists); void userPlaylistsChanged(const QVector<QPair<qint64, QString>> &playlists);
/// Emitted with all user playlist IDs (owned + subscribed).
void userPlaylistIdsChanged(const QSet<qint64> &playlistIds);
/// Emitted when the currently open playlist was deleted. /// Emitted when the currently open playlist was deleted.
void openPlaylistDeleted(); void openPlaylistDeleted();

View File

@@ -5,6 +5,7 @@
#include <QHeaderView> #include <QHeaderView>
#include <QMenu> #include <QMenu>
#include <QAction> #include <QAction>
#include <QScrollBar>
namespace List namespace List
{ {
@@ -41,12 +42,45 @@ Tracks::Tracks(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
setFirstColumnSpanned(row, {}, true); setFirstColumnSpanned(row, {}, true);
setSortingEnabled(!m_model->hasMultipleDiscs()); setSortingEnabled(!m_model->hasMultipleDiscs());
}); });
connect(verticalScrollBar(), &QScrollBar::valueChanged,
this, [this](int) { maybeLoadMorePlaylistTracks(); });
connect(m_backend, &QobuzBackend::playlistLoaded, this,
[this](const QJsonObject &playlist) {
if (!m_pendingPlayAll)
return;
const qint64 id = static_cast<qint64>(playlist["id"].toDouble());
if (id != m_playlistId)
return;
if (!playlist["full_load"].toBool())
return;
m_pendingPlayAll = false;
const bool shuffle = m_pendingPlayAllShuffle;
m_pendingPlayAllShuffle = false;
const QJsonArray items = playlist["tracks"].toObject()["items"].toArray();
if (items.isEmpty())
return;
m_queue->setContext(items, 0);
if (shuffle && !m_queue->shuffleEnabled())
m_queue->shuffleNow();
const qint64 firstId = static_cast<qint64>(m_queue->current()["id"].toDouble());
if (firstId > 0)
emit playTrackRequested(firstId);
});
} }
void Tracks::loadTracks(const QJsonArray &tracks) void Tracks::loadTracks(const QJsonArray &tracks)
{ {
setPlaylistContext(0); setPlaylistContext(0);
m_pendingPlayAll = false;
m_pendingPlayAllShuffle = false;
setColumnHidden(TrackListModel::ColAlbum, false); setColumnHidden(TrackListModel::ColAlbum, false);
m_model->setTracks(tracks, false, /*useSequential=*/true); m_model->setTracks(tracks, false, /*useSequential=*/true);
} }
@@ -54,6 +88,8 @@ void Tracks::loadTracks(const QJsonArray &tracks)
void Tracks::loadAlbum(const QJsonObject &album) void Tracks::loadAlbum(const QJsonObject &album)
{ {
setPlaylistContext(0); setPlaylistContext(0);
m_pendingPlayAll = false;
m_pendingPlayAllShuffle = false;
setColumnHidden(TrackListModel::ColAlbum, true); setColumnHidden(TrackListModel::ColAlbum, true);
const QJsonArray items = album["tracks"].toObject()["items"].toArray(); const QJsonArray items = album["tracks"].toObject()["items"].toArray();
m_model->setTracks(items); // album: use track_number m_model->setTracks(items); // album: use track_number
@@ -67,21 +103,72 @@ void Tracks::loadPlaylist(const QJsonObject &playlist)
const qint64 myId = AppSettings::instance().userId(); const qint64 myId = AppSettings::instance().userId();
const bool isOwned = (myId > 0 && ownId == myId); const bool isOwned = (myId > 0 && ownId == myId);
setPlaylistContext(id, isOwned); setPlaylistContext(id, isOwned);
const QJsonArray items = playlist["tracks"].toObject()["items"].toArray(); const QJsonObject tracksObj = playlist["tracks"].toObject();
const QJsonArray items = tracksObj["items"].toArray();
const int offset = tracksObj["offset"].toInt(0);
m_playlistTrackTotal = tracksObj["total"].toInt(items.size());
m_playlistLoadedCount = offset + items.size();
m_playlistLoadingMore = false;
m_model->setTracks(items, /*usePosition=*/true); m_model->setTracks(items, /*usePosition=*/true);
maybeLoadMorePlaylistTracks();
}
void Tracks::appendPlaylistPage(const QJsonObject &playlist)
{
if (m_playlistId <= 0)
return;
const qint64 id = static_cast<qint64>(playlist["id"].toDouble());
if (id != m_playlistId)
return;
const QJsonObject tracksObj = playlist["tracks"].toObject();
const QJsonArray items = tracksObj["items"].toArray();
const int offset = tracksObj["offset"].toInt(m_playlistLoadedCount);
const int total = tracksObj["total"].toInt(m_playlistTrackTotal);
if (total > 0)
m_playlistTrackTotal = total;
// Ignore stale/duplicate pages.
if (offset < m_playlistLoadedCount) {
m_playlistLoadingMore = false;
return;
}
if (!items.isEmpty()) {
m_model->appendTracks(items, /*usePosition=*/true);
m_playlistLoadedCount = offset + items.size();
} else {
m_playlistLoadedCount = qMax(m_playlistLoadedCount, offset);
}
m_playlistLoadingMore = false;
maybeLoadMorePlaylistTracks();
} }
void Tracks::loadSearchTracks(const QJsonArray &tracks) void Tracks::loadSearchTracks(const QJsonArray &tracks)
{ {
setPlaylistContext(0); setPlaylistContext(0);
m_pendingPlayAll = false;
m_pendingPlayAllShuffle = false;
setColumnHidden(TrackListModel::ColAlbum, false); setColumnHidden(TrackListModel::ColAlbum, false);
m_model->setTracks(tracks, false, /*useSequential=*/true); m_model->setTracks(tracks, false, /*useSequential=*/true);
} }
void Tracks::setPlaylistContext(qint64 playlistId, bool isOwned) void Tracks::setPlaylistContext(qint64 playlistId, bool isOwned)
{ {
if (m_playlistId != playlistId) {
m_pendingPlayAll = false;
m_pendingPlayAllShuffle = false;
m_playlistLoadingMore = false;
}
m_playlistId = playlistId; m_playlistId = playlistId;
m_playlistIsOwned = isOwned; m_playlistIsOwned = isOwned;
if (playlistId <= 0) {
m_playlistTrackTotal = 0;
m_playlistLoadedCount = 0;
}
} }
void Tracks::setUserPlaylists(const QVector<QPair<qint64, QString>> &playlists) void Tracks::setUserPlaylists(const QVector<QPair<qint64, QString>> &playlists)
@@ -113,6 +200,14 @@ void Tracks::playAll(bool shuffle)
{ {
const QJsonArray tracks = m_model->currentTracksJson(); const QJsonArray tracks = m_model->currentTracksJson();
if (tracks.isEmpty()) return; if (tracks.isEmpty()) return;
if (m_playlistId > 0 && m_playlistTrackTotal > tracks.size()) {
m_pendingPlayAll = true;
m_pendingPlayAllShuffle = shuffle;
m_backend->getPlaylistAll(m_playlistId);
return;
}
m_queue->setContext(tracks, 0); m_queue->setContext(tracks, 0);
// Shuffle once without touching the global shuffle flag — so a subsequent // 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). // double-click on a track plays in normal order (unless global shuffle is on).
@@ -123,6 +218,30 @@ void Tracks::playAll(bool shuffle)
emit playTrackRequested(firstId); emit playTrackRequested(firstId);
} }
void Tracks::maybeLoadMorePlaylistTracks()
{
if (m_playlistId <= 0)
return;
if (m_pendingPlayAll)
return;
if (m_playlistLoadingMore)
return;
if (m_playlistTrackTotal > 0 && m_playlistLoadedCount >= m_playlistTrackTotal)
return;
QScrollBar *bar = verticalScrollBar();
if (!bar)
return;
// Start prefetching before the absolute bottom so paging feels seamless.
constexpr int kPrefetchPx = 180;
if (bar->maximum() > 0 && bar->value() < (bar->maximum() - kPrefetchPx))
return;
m_playlistLoadingMore = true;
m_backend->getPlaylist(m_playlistId, static_cast<quint32>(m_playlistLoadedCount), 500);
}
void Tracks::onDoubleClicked(const QModelIndex &index) void Tracks::onDoubleClicked(const QModelIndex &index)
{ {
@@ -228,7 +347,7 @@ void Tracks::onContextMenu(const QPoint &pos)
m_model->data(index, TrackListModel::PlaylistTrackIdRole).toLongLong(); m_model->data(index, TrackListModel::PlaylistTrackIdRole).toLongLong();
if (playlistTrackId > 0) { if (playlistTrackId > 0) {
if (m_userPlaylists.isEmpty()) menu.addSeparator(); if (m_userPlaylists.isEmpty()) menu.addSeparator();
auto *remFromPl = menu.addAction(tr("Remove from this playlist")); auto *remFromPl = menu.addAction(QIcon(":/res/icons/list-remove.svg"), tr("Remove from this playlist"));
const qint64 curPlaylistId = m_playlistId; const qint64 curPlaylistId = m_playlistId;
const int curRow = index.row(); const int curRow = index.row();
connect(remFromPl, &QAction::triggered, this, [this, curPlaylistId, playlistTrackId, curRow] { connect(remFromPl, &QAction::triggered, this, [this, curPlaylistId, playlistTrackId, curRow] {

View File

@@ -24,6 +24,7 @@ namespace List
void loadTracks(const QJsonArray &tracks); void loadTracks(const QJsonArray &tracks);
void loadAlbum(const QJsonObject &album); void loadAlbum(const QJsonObject &album);
void loadPlaylist(const QJsonObject &playlist); void loadPlaylist(const QJsonObject &playlist);
void appendPlaylistPage(const QJsonObject &playlist);
void loadSearchTracks(const QJsonArray &tracks); void loadSearchTracks(const QJsonArray &tracks);
/// Called when the backend fires EV_TRACK_CHANGED so the playing row is highlighted. /// Called when the backend fires EV_TRACK_CHANGED so the playing row is highlighted.
@@ -55,9 +56,16 @@ namespace List
QobuzBackend *m_backend = nullptr; QobuzBackend *m_backend = nullptr;
PlayQueue *m_queue = nullptr; PlayQueue *m_queue = nullptr;
qint64 m_playlistId = 0; qint64 m_playlistId = 0;
int m_playlistTrackTotal = 0;
int m_playlistLoadedCount = 0;
bool m_playlistIsOwned = false; bool m_playlistIsOwned = false;
bool m_playlistLoadingMore = false;
bool m_pendingPlayAll = false;
bool m_pendingPlayAllShuffle = false;
QVector<QPair<qint64, QString>> m_userPlaylists; QVector<QPair<qint64, QString>> m_userPlaylists;
void maybeLoadMorePlaylistTracks();
void onDoubleClicked(const QModelIndex &index); void onDoubleClicked(const QModelIndex &index);
void onContextMenu(const QPoint &pos); void onContextMenu(const QPoint &pos);
}; };

View File

@@ -1,5 +1,6 @@
#include "mainwindow.hpp" #include "mainwindow.hpp"
#include "backend/qobuzbackend.hpp" #include "backend/qobuzbackend.hpp"
#include "util/colors.hpp"
#include <QApplication> #include <QApplication>
#include <QStyleFactory> #include <QStyleFactory>
@@ -15,24 +16,24 @@ int main(int argc, char *argv[])
// Accent: #FFB232 (yellow-orange), Blue: #46B3EE, Backgrounds: #191919 / #141414 // Accent: #FFB232 (yellow-orange), Blue: #46B3EE, Backgrounds: #191919 / #141414
app.setStyle(QStyleFactory::create(QStringLiteral("Fusion"))); app.setStyle(QStyleFactory::create(QStringLiteral("Fusion")));
QPalette darkPalette; QPalette darkPalette;
darkPalette.setColor(QPalette::Window, QColor(0x19, 0x19, 0x19)); darkPalette.setColor(QPalette::Window, Colors::WindowBg);
darkPalette.setColor(QPalette::WindowText, QColor(0xe8, 0xe8, 0xe8)); darkPalette.setColor(QPalette::WindowText, Colors::LightText);
darkPalette.setColor(QPalette::Base, QColor(0x14, 0x14, 0x14)); darkPalette.setColor(QPalette::Base, Colors::BaseBg);
darkPalette.setColor(QPalette::AlternateBase, QColor(0x1e, 0x1e, 0x1e)); darkPalette.setColor(QPalette::AlternateBase, Colors::AlternateBaseBg);
darkPalette.setColor(QPalette::ToolTipBase, QColor(0x19, 0x19, 0x19)); darkPalette.setColor(QPalette::ToolTipBase, Colors::WindowBg);
darkPalette.setColor(QPalette::ToolTipText, QColor(0xe8, 0xe8, 0xe8)); darkPalette.setColor(QPalette::ToolTipText, Colors::LightText);
darkPalette.setColor(QPalette::Text, QColor(0xe8, 0xe8, 0xe8)); darkPalette.setColor(QPalette::Text, Colors::LightText);
darkPalette.setColor(QPalette::Button, QColor(0x2a, 0x2a, 0x2a)); darkPalette.setColor(QPalette::Button, Colors::ButtonSurface);
darkPalette.setColor(QPalette::ButtonText, QColor(0xe8, 0xe8, 0xe8)); darkPalette.setColor(QPalette::ButtonText, Colors::LightText);
darkPalette.setColor(QPalette::BrightText, QColor(0xFF, 0xB2, 0x32)); darkPalette.setColor(QPalette::BrightText, Colors::QobuzOrange);
darkPalette.setColor(QPalette::Link, QColor(0x46, 0xB3, 0xEE)); // Qobuz blue darkPalette.setColor(QPalette::Link, Colors::QobuzBlue);
darkPalette.setColor(QPalette::Highlight, QColor(0xFF, 0xB2, 0x32)); // Qobuz orange darkPalette.setColor(QPalette::Highlight, Colors::QobuzOrange);
darkPalette.setColor(QPalette::HighlightedText, QColor(0x10, 0x10, 0x10)); // dark on orange darkPalette.setColor(QPalette::HighlightedText, Colors::HighlightedFg);
darkPalette.setColor(QPalette::PlaceholderText, QColor(0x66, 0x66, 0x66)); darkPalette.setColor(QPalette::PlaceholderText, Colors::PlaceholderText);
darkPalette.setColor(QPalette::Disabled, QPalette::Text, QColor(0x55, 0x55, 0x55)); darkPalette.setColor(QPalette::Disabled, QPalette::Text, Colors::DisabledText);
darkPalette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor(0x55, 0x55, 0x55)); darkPalette.setColor(QPalette::Disabled, QPalette::ButtonText, Colors::DisabledText);
darkPalette.setColor(QPalette::Mid, QColor(0x2f, 0x2f, 0x2f)); darkPalette.setColor(QPalette::Mid, Colors::MidSurface);
darkPalette.setColor(QPalette::Dark, QColor(0x0e, 0x0e, 0x0e)); darkPalette.setColor(QPalette::Dark, Colors::DarkSurface);
app.setPalette(darkPalette); app.setPalette(darkPalette);
// Stylesheet tweaks: orange accent on scrollbars, focus rings, etc. // Stylesheet tweaks: orange accent on scrollbars, focus rings, etc.

View File

@@ -38,6 +38,27 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
m_content = new MainContent(m_backend, m_queue, this); m_content = new MainContent(m_backend, m_queue, this);
setCentralWidget(m_content); setCentralWidget(m_content);
setupDocks();
setupMenuBar();
statusBar()->showMessage(tr("Ready"));
setupScrobbler();
setupGapless();
setupMpris();
connectBackendSignals();
connectLibrarySignals();
connectContentSignals();
connectToolbarSignals();
// Apply playback options from saved settings
m_backend->setReplayGain(AppSettings::instance().replayGainEnabled());
m_backend->setGapless(AppSettings::instance().gaplessEnabled());
tryRestoreSession();
}
void MainWindow::setupDocks()
{
// ---- Library dock (left) ---- // ---- Library dock (left) ----
m_library = new List::Library(m_backend, this); m_library = new List::Library(m_backend, this);
m_libraryDock = new QDockWidget(tr("Library"), this); m_libraryDock = new QDockWidget(tr("Library"), this);
@@ -60,11 +81,10 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
m_sidePanel = new SidePanel::View(m_backend, m_queue, this); m_sidePanel = new SidePanel::View(m_backend, m_queue, this);
m_sidePanel->hide(); m_sidePanel->hide();
addDockWidget(Qt::RightDockWidgetArea, m_sidePanel); addDockWidget(Qt::RightDockWidgetArea, m_sidePanel);
}
setupMenuBar(); void MainWindow::setupScrobbler()
statusBar()->showMessage(tr("Ready")); {
// ---- Scrobbler ----
m_scrobbler = new LastFmScrobbler(this); m_scrobbler = new LastFmScrobbler(this);
connect(m_backend, &QobuzBackend::trackChanged, connect(m_backend, &QobuzBackend::trackChanged,
m_scrobbler, &LastFmScrobbler::onTrackStarted); m_scrobbler, &LastFmScrobbler::onTrackStarted);
@@ -73,11 +93,13 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
connect(m_backend, &QobuzBackend::trackFinished, connect(m_backend, &QobuzBackend::trackFinished,
m_scrobbler, &LastFmScrobbler::onTrackFinished); m_scrobbler, &LastFmScrobbler::onTrackFinished);
// 1. Scrobble the finished track during a gapless transition // Scrobble the finished track during a gapless transition
connect(m_backend, &QobuzBackend::trackTransitioned, connect(m_backend, &QobuzBackend::trackTransitioned,
m_scrobbler, &LastFmScrobbler::onTrackFinished); m_scrobbler, &LastFmScrobbler::onTrackFinished);
}
// ---- Gapless Signal ---- void MainWindow::setupGapless()
{
connect(m_backend, &QobuzBackend::positionChanged, this, [this](quint64 pos, quint64 dur) { connect(m_backend, &QobuzBackend::positionChanged, this, [this](quint64 pos, quint64 dur) {
if (!AppSettings::instance().gaplessEnabled() || dur == 0) return; if (!AppSettings::instance().gaplessEnabled() || dur == 0) return;
@@ -96,57 +118,10 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
} }
} }
}); });
// ---- Backend signals ----
connect(m_backend, &QobuzBackend::loginSuccess, this, &MainWindow::onLoginSuccess);
connect(m_backend, &QobuzBackend::loginError, this, &MainWindow::onLoginError);
connect(m_backend, &QobuzBackend::userLoaded, this, [this](const QJsonObject &user) {
const qint64 id = static_cast<qint64>(user["id"].toDouble());
if (id > 0) {
AppSettings::instance().setUserId(id);
m_library->refresh(); // re-load playlists with correct ownership now
} }
});
connect(m_backend, &QobuzBackend::favTracksLoaded, this, &MainWindow::onFavTracksLoaded);
connect(m_backend, &QobuzBackend::favAlbumsLoaded, this, &MainWindow::onFavAlbumsLoaded);
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::artistReleasesLoaded,
m_content, &MainContent::updateArtistReleases);
connect(m_backend, &QobuzBackend::deepShuffleTracksLoaded,
m_content, &MainContent::onDeepShuffleTracks);
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();
statusBar()->showMessage(tr("Loading favorite tracks…"));
});
void MainWindow::setupMpris()
{
#ifdef USE_DBUS #ifdef USE_DBUS
m_mpris = new Mpris(this); m_mpris = new Mpris(this);
connect(m_mpris->player(), &MprisPlayerAdaptor::playRequested, m_backend, [this] { connect(m_mpris->player(), &MprisPlayerAdaptor::playRequested, m_backend, [this] {
@@ -191,8 +166,83 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
m_mpris->player()->updatePosition(pos); m_mpris->player()->updatePosition(pos);
}); });
#endif #endif
}
void MainWindow::connectBackendSignals()
{
connect(m_backend, &QobuzBackend::loginSuccess, this, &MainWindow::onLoginSuccess);
connect(m_backend, &QobuzBackend::loginError, this, &MainWindow::onLoginError);
connect(m_backend, &QobuzBackend::userLoaded, this, [this](const QJsonObject &user) {
const qint64 id = static_cast<qint64>(user["id"].toDouble());
if (id > 0) {
AppSettings::instance().setUserId(id);
m_library->refresh(); // re-load playlists with correct ownership now
}
});
connect(m_backend, &QobuzBackend::favTracksLoaded, this, &MainWindow::onFavTracksLoaded);
connect(m_backend, &QobuzBackend::favAlbumsLoaded, this, &MainWindow::onFavAlbumsLoaded);
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::artistReleasesLoaded,
m_content, &MainContent::updateArtistReleases);
connect(m_backend, &QobuzBackend::deepShuffleTracksLoaded,
m_content, &MainContent::onDeepShuffleTracks);
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::playlistSubscribed, this, [this](qint64 playlistId) {
m_userPlaylistIds.insert(playlistId);
m_library->refresh();
if (m_content->tracksList()->playlistId() == playlistId)
m_content->setCurrentPlaylistFollowed(true);
statusBar()->showMessage(tr("Playlist followed"), 3000);
});
connect(m_backend, &QobuzBackend::playlistUnsubscribed, this, [this](qint64 playlistId) {
m_userPlaylistIds.remove(playlistId);
m_library->refresh();
if (m_content->tracksList()->playlistId() == playlistId)
m_content->setCurrentPlaylistFollowed(false);
statusBar()->showMessage(tr("Playlist unfollowed"), 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);
});
}
void MainWindow::connectLibrarySignals()
{
connect(m_library, &List::Library::userPlaylistIdsChanged,
this, [this](const QSet<qint64> &playlistIds) {
m_userPlaylistIds = playlistIds;
const qint64 currentPlaylistId = m_content->tracksList()->playlistId();
if (currentPlaylistId > 0)
m_content->setCurrentPlaylistFollowed(m_userPlaylistIds.contains(currentPlaylistId));
});
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();
statusBar()->showMessage(tr("Loading favorite tracks…"));
});
connect(m_library, &List::Library::favAlbumsRequested, this, [this] { connect(m_library, &List::Library::favAlbumsRequested, this, [this] {
m_showFavAlbumsOnLoad = true;
m_backend->getFavAlbums(); m_backend->getFavAlbums();
statusBar()->showMessage(tr("Loading favorite albums…")); statusBar()->showMessage(tr("Loading favorite albums…"));
}); });
@@ -210,7 +260,14 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
m_content->showGenreBrowser(); m_content->showGenreBrowser();
statusBar()->showMessage(tr("Browse Genres")); statusBar()->showMessage(tr("Browse Genres"));
}); });
connect(m_library, &List::Library::browsePlaylistsRequested, this, [this] {
m_content->showPlaylistBrowser();
statusBar()->showMessage(tr("Browse Playlists"));
});
}
void MainWindow::connectContentSignals()
{
// ---- Track list → playback / playlist management ---- // ---- Track list → playback / playlist management ----
connect(m_content->tracksList(), &List::Tracks::playTrackRequested, connect(m_content->tracksList(), &List::Tracks::playTrackRequested,
this, &MainWindow::onPlayTrackRequested); this, &MainWindow::onPlayTrackRequested);
@@ -243,26 +300,62 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
this, &MainWindow::onSearchAlbumSelected); this, &MainWindow::onSearchAlbumSelected);
connect(m_content, &MainContent::artistRequested, connect(m_content, &MainContent::artistRequested,
this, &MainWindow::onSearchArtistSelected); this, &MainWindow::onSearchArtistSelected);
connect(m_content, &MainContent::albumFavoriteToggled,
this, [this](const QString &albumId, bool favorite) {
if (favorite) {
m_backend->addFavAlbum(albumId);
m_favAlbumIds.insert(albumId);
statusBar()->showMessage(tr("Added album to favorites"), 3000);
} else {
m_backend->removeFavAlbum(albumId);
m_favAlbumIds.remove(albumId);
statusBar()->showMessage(tr("Removed album from favorites"), 3000);
}
m_content->setFavAlbumIds(m_favAlbumIds);
});
connect(m_content, &MainContent::playlistRequested,
this, [this](qint64 playlistId) {
m_backend->getPlaylist(playlistId);
statusBar()->showMessage(tr("Loading playlist…"));
});
connect(m_content, &MainContent::playlistFollowToggled,
this, [this](qint64 playlistId, bool follow) {
if (follow)
m_backend->subscribePlaylist(playlistId);
else
m_backend->unsubscribePlaylist(playlistId);
});
connect(m_content, &MainContent::playTrackRequested, connect(m_content, &MainContent::playTrackRequested,
this, &MainWindow::onPlayTrackRequested); this, &MainWindow::onPlayTrackRequested);
// ---- Queue panel ---- // ---- Queue panel ----
connect(m_queuePanel, &QueuePanel::skipToTrackRequested, connect(m_queuePanel, &QueuePanel::skipToTrackRequested,
this, &MainWindow::onPlayTrackRequested); this, &MainWindow::onPlayTrackRequested);
}
// ---- Toolbar toggles ---- void MainWindow::connectToolbarSignals()
{
connect(m_toolBar, &MainToolBar::searchToggled, this, &MainWindow::onSearchToggled); connect(m_toolBar, &MainToolBar::searchToggled, this, &MainWindow::onSearchToggled);
connect(m_toolBar, &MainToolBar::queueToggled, connect(m_toolBar, &MainToolBar::queueToggled,
this, [this](bool v) { m_queuePanel->setVisible(v); }); this, [this](bool v) { m_queuePanel->setVisible(v); });
connect(m_queuePanel, &QDockWidget::visibilityChanged,
m_toolBar, &MainToolBar::setQueueToggleChecked);
connect(m_sidePanel, &QDockWidget::visibilityChanged,
m_toolBar, &MainToolBar::setSearchToggleChecked);
m_toolBar->setQueueToggleChecked(m_queuePanel->isVisible());
m_toolBar->setSearchToggleChecked(m_sidePanel->isVisible());
connect(m_toolBar, &MainToolBar::albumRequested, this, &MainWindow::onSearchAlbumSelected); connect(m_toolBar, &MainToolBar::albumRequested, this, &MainWindow::onSearchAlbumSelected);
connect(m_toolBar, &MainToolBar::artistRequested, this, &MainWindow::onSearchArtistSelected); connect(m_toolBar, &MainToolBar::artistRequested, this, &MainWindow::onSearchArtistSelected);
connect(m_toolBar, &MainToolBar::addToPlaylistRequested,
// Apply playback options from saved settings this, [this](qint64 trackId, qint64 playlistId) {
m_backend->setReplayGain(AppSettings::instance().replayGainEnabled()); m_backend->addTrackToPlaylist(playlistId, trackId);
m_backend->setGapless(AppSettings::instance().gaplessEnabled()); statusBar()->showMessage(tr("Adding track to playlist…"), 3000);
});
tryRestoreSession(); connect(m_toolBar, &MainToolBar::favTrackRequested,
this, [this](qint64 trackId) {
m_backend->addFavTrack(trackId);
});
} }
void MainWindow::setupMenuBar() void MainWindow::setupMenuBar()
@@ -303,6 +396,8 @@ void MainWindow::tryRestoreSession()
m_backend->getUser(); // userLoaded will call m_library->refresh() m_backend->getUser(); // userLoaded will call m_library->refresh()
else else
m_library->refresh(); m_library->refresh();
// Preload fav albums so the album page fav button is accurate immediately.
m_backend->getFavAlbums();
// Preload fav artists so the artist page fav button works immediately // Preload fav artists so the artist page fav button works immediately
m_backend->getFavArtists(); m_backend->getFavArtists();
const QString name = AppSettings::instance().displayName(); const QString name = AppSettings::instance().displayName();
@@ -354,6 +449,8 @@ void MainWindow::onLoginSuccess(const QString &token, const QJsonObject &user)
statusBar()->showMessage(tr("Signed in as %1").arg( statusBar()->showMessage(tr("Signed in as %1").arg(
displayName.isEmpty() ? email : displayName)); displayName.isEmpty() ? email : displayName));
m_library->refresh(); m_library->refresh();
m_backend->getFavAlbums();
m_backend->getFavArtists();
} }
void MainWindow::onLoginError(const QString &error) void MainWindow::onLoginError(const QString &error)
@@ -415,10 +512,27 @@ void MainWindow::onFavTracksLoaded(const QJsonObject &result)
void MainWindow::onFavAlbumsLoaded(const QJsonObject &result) void MainWindow::onFavAlbumsLoaded(const QJsonObject &result)
{ {
// Always cache fav album IDs (needed by the album page fav button)
m_favAlbumIds.clear();
const QJsonArray items = result["items"].toArray();
for (const QJsonValue &v : items) {
const QJsonObject album = v.toObject();
QString id = album["id"].toString();
if (id.isEmpty() && album["id"].isDouble())
id = QString::number(static_cast<qint64>(album["id"].toDouble()));
if (!id.isEmpty())
m_favAlbumIds.insert(id);
}
m_content->setFavAlbumIds(m_favAlbumIds);
// Only navigate to the fav albums page if the user explicitly requested it
if (m_showFavAlbumsOnLoad) {
m_showFavAlbumsOnLoad = false;
m_content->showFavAlbums(result); m_content->showFavAlbums(result);
statusBar()->showMessage( statusBar()->showMessage(
tr("%1 favorite albums").arg(result["total"].toInt()), 4000); tr("%1 favorite albums").arg(result["total"].toInt()), 4000);
} }
}
void MainWindow::onFavArtistsLoaded(const QJsonObject &result) void MainWindow::onFavArtistsLoaded(const QJsonObject &result)
{ {
@@ -462,7 +576,27 @@ void MainWindow::onArtistLoaded(const QJsonObject &artist)
void MainWindow::onPlaylistLoaded(const QJsonObject &playlist) void MainWindow::onPlaylistLoaded(const QJsonObject &playlist)
{ {
m_content->showPlaylist(playlist); const bool fullLoad = playlist["full_load"].toBool(false);
const int trackOffset = playlist["tracks"].toObject()["offset"].toInt(0);
if (!fullLoad && trackOffset > 0) {
m_content->tracksList()->appendPlaylistPage(playlist);
return;
}
const qint64 id = static_cast<qint64>(playlist["id"].toDouble());
const qint64 ownerId = static_cast<qint64>(playlist["owner"].toObject()["id"].toDouble());
const qint64 myId = AppSettings::instance().userId();
const bool isOwned = (myId > 0 && ownerId == myId);
bool isFollowed = isOwned || m_userPlaylistIds.contains(id);
if (!isFollowed) {
if (playlist.contains("is_subscribed"))
isFollowed = playlist["is_subscribed"].toBool();
else if (playlist.contains("subscribed_at"))
isFollowed = !playlist["subscribed_at"].isNull();
}
m_content->showPlaylist(playlist, isFollowed, isOwned);
statusBar()->showMessage( statusBar()->showMessage(
tr("Playlist: %1").arg(playlist["name"].toString()), 4000); tr("Playlist: %1").arg(playlist["name"].toString()), 4000);
} }
@@ -504,4 +638,5 @@ void MainWindow::onUserPlaylistsChanged(const QVector<QPair<qint64, QString>> &p
m_userPlaylists = playlists; m_userPlaylists = playlists;
m_content->tracksList()->setUserPlaylists(playlists); m_content->tracksList()->setUserPlaylists(playlists);
m_sidePanel->searchTab()->setUserPlaylists(playlists); m_sidePanel->searchTab()->setUserPlaylists(playlists);
m_toolBar->setUserPlaylists(playlists);
} }

View File

@@ -55,7 +55,10 @@ private:
QobuzBackend *m_backend = nullptr; QobuzBackend *m_backend = nullptr;
PlayQueue *m_queue = nullptr; PlayQueue *m_queue = nullptr;
QVector<QPair<qint64, QString>> m_userPlaylists; QVector<QPair<qint64, QString>> m_userPlaylists;
QSet<qint64> m_userPlaylistIds;
QSet<QString> m_favAlbumIds;
QSet<qint64> m_favArtistIds; QSet<qint64> m_favArtistIds;
bool m_showFavAlbumsOnLoad = false;
bool m_showFavArtistsOnLoad = false; bool m_showFavArtistsOnLoad = false;
MainToolBar *m_toolBar = nullptr; MainToolBar *m_toolBar = nullptr;
MainContent *m_content = nullptr; MainContent *m_content = nullptr;
@@ -69,5 +72,13 @@ private:
bool m_nextTrackPrefetched = false; bool m_nextTrackPrefetched = false;
void setupMenuBar(); void setupMenuBar();
void setupDocks();
void setupScrobbler();
void setupGapless();
void setupMpris();
void connectBackendSignals();
void connectLibrarySignals();
void connectContentSignals();
void connectToolbarSignals();
void tryRestoreSession(); void tryRestoreSession();
}; };

View File

@@ -1,4 +1,5 @@
#include "tracklistmodel.hpp" #include "tracklistmodel.hpp"
#include "../util/colors.hpp"
#include <QJsonValue> #include <QJsonValue>
#include <QColor> #include <QColor>
@@ -9,21 +10,11 @@ TrackListModel::TrackListModel(QObject *parent)
: QAbstractTableModel(parent) : QAbstractTableModel(parent)
{} {}
void TrackListModel::setTracks(const QJsonArray &tracks, TrackItem TrackListModel::parseTrackItem(const QJsonObject &t,
bool usePosition, bool usePosition,
bool useSequential) bool useSequential,
int &seq)
{ {
beginResetModel();
m_tracks.clear();
m_tracks.reserve(tracks.size());
// Parse into a temporary list first so we can detect multi-disc
QVector<TrackItem> parsed;
parsed.reserve(tracks.size());
int seq = 1;
for (const QJsonValue &v : tracks) {
const QJsonObject t = v.toObject();
TrackItem item; TrackItem item;
item.id = static_cast<qint64>(t["id"].toDouble()); item.id = static_cast<qint64>(t["id"].toDouble());
item.playlistTrackId = static_cast<qint64>(t["playlist_track_id"].toDouble()); item.playlistTrackId = static_cast<qint64>(t["playlist_track_id"].toDouble());
@@ -66,9 +57,25 @@ void TrackListModel::setTracks(const QJsonArray &tracks,
item.album = album["title"].toString(); item.album = album["title"].toString();
item.albumId = album["id"].toString(); item.albumId = album["id"].toString();
parsed.append(item); return item;
} }
void TrackListModel::setTracks(const QJsonArray &tracks,
bool usePosition,
bool useSequential)
{
beginResetModel();
m_tracks.clear();
m_tracks.reserve(tracks.size());
// Parse into a temporary list first so we can detect multi-disc
QVector<TrackItem> parsed;
parsed.reserve(tracks.size());
int seq = 1;
for (const QJsonValue &v : tracks)
parsed.append(parseTrackItem(v.toObject(), usePosition, useSequential, seq));
// Multi-disc only makes sense for album context (not playlists / fav / search) // Multi-disc only makes sense for album context (not playlists / fav / search)
int maxDisc = 1; int maxDisc = 1;
if (!usePosition && !useSequential) { if (!usePosition && !useSequential) {
@@ -109,6 +116,51 @@ void TrackListModel::setTracks(const QJsonArray &tracks,
emit sortApplied(); emit sortApplied();
} }
void TrackListModel::appendTracks(const QJsonArray &tracks,
bool usePosition,
bool useSequential)
{
if (tracks.isEmpty())
return;
// Keep append path simple and stable: disc-header mode is handled by reset path.
if (m_hasMultipleDiscs && !usePosition && !useSequential) {
QJsonArray all = currentTracksJson();
for (const QJsonValue &v : tracks)
all.append(v);
setTracks(all, usePosition, useSequential);
return;
}
int seq = 1;
if (useSequential || usePosition) {
for (const TrackItem &t : m_tracks)
if (!t.isDiscHeader)
++seq;
}
QVector<TrackItem> parsed;
parsed.reserve(tracks.size());
for (const QJsonValue &v : tracks)
parsed.append(parseTrackItem(v.toObject(), usePosition, useSequential, seq));
if (parsed.isEmpty())
return;
const int first = m_tracks.size();
const int last = first + parsed.size() - 1;
beginInsertRows({}, first, last);
m_tracks += parsed;
endInsertRows();
if (!m_hasMultipleDiscs && m_sortColumn >= 0) {
emit layoutAboutToBeChanged();
sortData(m_sortColumn, m_sortOrder);
emit layoutChanged();
emit sortApplied();
}
}
void TrackListModel::clear() void TrackListModel::clear()
{ {
beginResetModel(); beginResetModel();
@@ -124,6 +176,16 @@ void TrackListModel::removeTrack(int row)
endRemoveRows(); endRemoveRows();
} }
void TrackListModel::notifyFavChanged(qint64 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::setFavIds(const QSet<qint64> &ids) void TrackListModel::setFavIds(const QSet<qint64> &ids)
{ {
m_favIds = ids; m_favIds = ids;
@@ -135,23 +197,13 @@ void TrackListModel::setFavIds(const QSet<qint64> &ids)
void TrackListModel::addFavId(qint64 id) void TrackListModel::addFavId(qint64 id)
{ {
m_favIds.insert(id); m_favIds.insert(id);
for (int r = 0; r < m_tracks.size(); ++r) { notifyFavChanged(id);
if (m_tracks[r].id == id) {
const auto idx = index(r, ColTitle);
emit dataChanged(idx, idx, {Qt::DecorationRole});
}
}
} }
void TrackListModel::removeFavId(qint64 id) void TrackListModel::removeFavId(qint64 id)
{ {
m_favIds.remove(id); m_favIds.remove(id);
for (int r = 0; r < m_tracks.size(); ++r) { notifyFavChanged(id);
if (m_tracks[r].id == id) {
const auto idx = index(r, ColTitle);
emit dataChanged(idx, idx, {Qt::DecorationRole});
}
}
} }
void TrackListModel::setPlayingId(qint64 id) void TrackListModel::setPlayingId(qint64 id)
@@ -204,7 +256,7 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const
QFont f; f.setBold(true); return f; QFont f; f.setBold(true); return f;
} }
if (role == Qt::ForegroundRole) if (role == Qt::ForegroundRole)
return QColor(0xFF, 0xB2, 0x32); return Colors::QobuzOrange;
return {}; return {};
} }
@@ -227,8 +279,8 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const
} }
if (role == Qt::ForegroundRole) { if (role == Qt::ForegroundRole) {
if (!t.streamable) return QColor(0x55, 0x55, 0x55); if (!t.streamable) return Colors::DisabledText;
if (isPlaying) return QColor(0xFF, 0xB2, 0x32); // Qobuz orange if (isPlaying) return Colors::QobuzOrange;
} }
if (role == Qt::DecorationRole && index.column() == ColNumber && isPlaying) { if (role == Qt::DecorationRole && index.column() == ColNumber && isPlaying) {

View File

@@ -51,6 +51,9 @@ public:
void setTracks(const QJsonArray &tracks, void setTracks(const QJsonArray &tracks,
bool usePosition = false, bool usePosition = false,
bool useSequential = false); bool useSequential = false);
void appendTracks(const QJsonArray &tracks,
bool usePosition = false,
bool useSequential = false);
void clear(); void clear();
void setPlayingId(qint64 id); void setPlayingId(qint64 id);
qint64 playingId() const { return m_playingId; } qint64 playingId() const { return m_playingId; }
@@ -102,4 +105,10 @@ private:
// Sort m_tracks in-place without emitting any signals. // Sort m_tracks in-place without emitting any signals.
void sortData(int column, Qt::SortOrder order); void sortData(int column, Qt::SortOrder order);
// Parse a single JSON track object into a TrackItem.
static TrackItem parseTrackItem(const QJsonObject &t, bool usePosition, bool useSequential, int &seq);
// Emit dataChanged(DecorationRole) for all rows matching id.
void notifyFavChanged(qint64 id);
}; };

238
src/playqueue.cpp Normal file
View File

@@ -0,0 +1,238 @@
#include "playqueue.hpp"
#include <algorithm>
#include <random>
PlayQueue::PlayQueue(QObject *parent) : QObject(parent) {}
void PlayQueue::setContext(const QJsonArray &tracks, int startIndex)
{
m_queue.clear();
m_playNext.clear();
// 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();
}
void PlayQueue::reorderContext(const QJsonArray &tracks, qint64 currentId)
{
m_queue.clear();
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) {
if (static_cast<qint64>(m_queue[i]["id"].toDouble()) == currentId) {
m_index = i;
break;
}
}
emit queueChanged();
}
void PlayQueue::clearUpcoming()
{
m_playNext.clear();
if (m_index < m_queue.size())
m_queue.resize(m_index + 1); // keep up to and including current
emit queueChanged();
}
void PlayQueue::removeUpcoming(int upcomingIndex)
{
if (upcomingIndex < m_playNext.size()) {
m_playNext.removeAt(upcomingIndex);
} else {
const int queueIdx = m_index + 1 + (upcomingIndex - m_playNext.size());
if (queueIdx < m_queue.size())
m_queue.removeAt(queueIdx);
}
emit queueChanged();
}
void PlayQueue::setShuffle(bool enabled)
{
if (m_shuffle == enabled) return;
m_shuffle = enabled;
if (enabled && !m_queue.isEmpty())
shuffleQueue(m_index);
emit queueChanged();
}
void PlayQueue::shuffleNow()
{
if (m_queue.isEmpty()) return;
shuffleQueue(m_index);
emit queueChanged();
}
void PlayQueue::addToQueue(const QJsonObject &track)
{
m_playNext.append(track);
emit queueChanged();
}
void PlayQueue::playNext(const QJsonObject &track)
{
m_playNext.prepend(track);
emit queueChanged();
}
bool PlayQueue::hasCurrent() const
{
return (!m_playNext.isEmpty()) || (!m_queue.isEmpty());
}
QJsonObject PlayQueue::current() const
{
if (!m_playNext.isEmpty()) return m_playNext.first();
if (m_index < m_queue.size()) return m_queue.at(m_index);
return {};
}
qint64 PlayQueue::currentId() const
{
return static_cast<qint64>(current()["id"].toDouble());
}
QJsonObject PlayQueue::advance()
{
if (!m_playNext.isEmpty()) {
// Return the playNext item directly — do NOT call current() after
// removal, as that would fall back to the already-playing m_index track.
const QJsonObject next = m_playNext.takeFirst();
emit queueChanged();
return next;
}
++m_index;
emit queueChanged();
return current();
}
QJsonObject PlayQueue::stepBack()
{
if (m_index > 0) --m_index;
emit queueChanged();
return current();
}
bool PlayQueue::canGoNext() const
{
return !m_playNext.isEmpty() || (m_index + 1 < m_queue.size());
}
void PlayQueue::setCurrentById(qint64 id)
{
m_playNext.clear();
for (int i = 0; i < m_queue.size(); ++i) {
if (static_cast<qint64>(m_queue[i]["id"].toDouble()) == id) {
m_index = i;
emit queueChanged();
return;
}
}
}
QVector<QJsonObject> PlayQueue::upcomingTracks(int maxCount) const
{
QVector<QJsonObject> result;
result.append(m_playNext);
for (int i = m_index + 1; i < m_queue.size() && result.size() < maxCount; ++i)
result.append(m_queue.at(i));
return result;
}
QJsonObject PlayQueue::skipToUpcoming(int upcomingIndex)
{
// Remove items 0..upcomingIndex-1 from the front of upcoming
for (int i = 0; i < upcomingIndex; ++i) {
if (!m_playNext.isEmpty())
m_playNext.removeFirst();
else if (m_index + 1 < m_queue.size())
++m_index;
}
// Pop and return the target (now at upcoming[0])
if (!m_playNext.isEmpty()) {
const QJsonObject t = m_playNext.takeFirst();
emit queueChanged();
return t;
}
if (m_index + 1 < m_queue.size()) {
++m_index;
emit queueChanged();
return m_queue.at(m_index);
}
emit queueChanged();
return {};
}
void PlayQueue::setUpcomingOrder(const QVector<QJsonObject> &newOrder)
{
m_playNext = newOrder;
m_queue.resize(m_index + 1); // drop old main-queue tail
emit queueChanged();
}
void PlayQueue::appendToContext(const QJsonArray &tracks)
{
for (const auto &v : tracks) {
const QJsonObject t = v.toObject();
if (t["streamable"].toBool(true))
m_queue.append(t);
}
emit queueChanged();
}
void PlayQueue::moveUpcomingToTop(int upcomingIndex)
{
if (upcomingIndex < 0) return;
QJsonObject track;
if (upcomingIndex < m_playNext.size()) {
if (upcomingIndex == 0) return; // already at top
track = m_playNext.takeAt(upcomingIndex);
} else {
const int queueIdx = m_index + 1 + (upcomingIndex - m_playNext.size());
if (queueIdx >= m_queue.size()) return;
track = m_queue.takeAt(queueIdx);
}
m_playNext.prepend(track);
emit queueChanged();
}
void PlayQueue::shuffleQueue(int keepAtFront)
{
if (m_queue.isEmpty()) return;
// Keep the current track at index 0 of the remaining queue
if (keepAtFront >= 0 && keepAtFront < m_queue.size()) {
QJsonObject current = m_queue.takeAt(keepAtFront);
std::mt19937 rng(std::random_device{}());
std::shuffle(m_queue.begin(), m_queue.end(), rng);
m_queue.prepend(current);
} else {
std::mt19937 rng(std::random_device{}());
std::shuffle(m_queue.begin(), m_queue.end(), rng);
}
m_index = 0;
}

View File

@@ -4,8 +4,6 @@
#include <QVector> #include <QVector>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonArray> #include <QJsonArray>
#include <algorithm>
#include <random>
/// Local playback queue. Holds the ordered list of tracks for the current /// Local playback queue. Holds the ordered list of tracks for the current
/// context (album / playlist / search result / favourites) plus a separate /// context (album / playlist / search result / favourites) plus a separate
@@ -15,248 +13,83 @@ class PlayQueue : public QObject
Q_OBJECT Q_OBJECT
public: public:
explicit PlayQueue(QObject *parent = nullptr) : QObject(parent) {} explicit PlayQueue(QObject *parent = nullptr);
// ---- Loading a new context ---- // ---- Loading a new context ----
/// Replace the queue with all tracks from an album/playlist JSON context. /// Replace the queue with all tracks from an album/playlist JSON context.
/// @param startIndex Index of the track to start playing (-1 = first). /// @param startIndex Index of the track to start playing (-1 = first).
void setContext(const QJsonArray &tracks, int startIndex = 0) void setContext(const QJsonArray &tracks, int startIndex = 0);
{
m_queue.clear();
m_playNext.clear();
// 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();
}
// ---- Re-order after a sort (keeps m_playNext, updates m_index) ---- // ---- Re-order after a sort (keeps m_playNext, updates m_index) ----
void reorderContext(const QJsonArray &tracks, qint64 currentId) void reorderContext(const QJsonArray &tracks, qint64 currentId);
{
m_queue.clear();
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) {
if (static_cast<qint64>(m_queue[i]["id"].toDouble()) == currentId) {
m_index = i;
break;
}
}
emit queueChanged();
}
// ---- Clear / remove upcoming ---- // ---- Clear / remove upcoming ----
/// Remove all "up next" entries (playNext + remaining main queue after current). /// Remove all "up next" entries (playNext + remaining main queue after current).
void clearUpcoming() void clearUpcoming();
{
m_playNext.clear();
if (m_index < m_queue.size())
m_queue.resize(m_index + 1); // keep up to and including current
emit queueChanged();
}
/// Remove one upcoming track by its index in upcomingTracks(). /// Remove one upcoming track by its index in upcomingTracks().
void removeUpcoming(int upcomingIndex) void removeUpcoming(int upcomingIndex);
{
if (upcomingIndex < m_playNext.size()) {
m_playNext.removeAt(upcomingIndex);
} else {
const int queueIdx = m_index + 1 + (upcomingIndex - m_playNext.size());
if (queueIdx < m_queue.size())
m_queue.removeAt(queueIdx);
}
emit queueChanged();
}
// ---- Shuffle ---- // ---- Shuffle ----
bool shuffleEnabled() const { return m_shuffle; } bool shuffleEnabled() const { return m_shuffle; }
void setShuffle(bool enabled) void setShuffle(bool enabled);
{
if (m_shuffle == enabled) return;
m_shuffle = enabled;
if (enabled && !m_queue.isEmpty())
shuffleQueue(m_index);
emit queueChanged();
}
/// Shuffle the current queue once without changing the global shuffle flag. /// Shuffle the current queue once without changing the global shuffle flag.
void shuffleNow() void shuffleNow();
{
if (m_queue.isEmpty()) return;
shuffleQueue(m_index);
emit queueChanged();
}
// ---- Play-next prepend queue (like "Add to queue" ---- // ---- Play-next prepend queue (like "Add to queue" ----
void addToQueue(const QJsonObject &track) void addToQueue(const QJsonObject &track);
{
m_playNext.append(track);
emit queueChanged();
}
void playNext(const QJsonObject &track) void playNext(const QJsonObject &track);
{
m_playNext.prepend(track);
emit queueChanged();
}
// ---- Navigation ---- // ---- Navigation ----
bool hasCurrent() const bool hasCurrent() const;
{
return (!m_playNext.isEmpty()) || (!m_queue.isEmpty());
}
QJsonObject current() const QJsonObject current() const;
{
if (!m_playNext.isEmpty()) return m_playNext.first();
if (m_index < m_queue.size()) return m_queue.at(m_index);
return {};
}
qint64 currentId() const qint64 currentId() const;
{
return static_cast<qint64>(current()["id"].toDouble());
}
/// Advance and return the track to play next. Returns {} at end of queue. /// Advance and return the track to play next. Returns {} at end of queue.
QJsonObject advance() QJsonObject advance();
{
if (!m_playNext.isEmpty()) {
// Return the playNext item directly — do NOT call current() after
// removal, as that would fall back to the already-playing m_index track.
const QJsonObject next = m_playNext.takeFirst();
emit queueChanged();
return next;
}
++m_index;
emit queueChanged();
return current();
}
/// Step backwards in the main queue (play-next is not affected). /// Step backwards in the main queue (play-next is not affected).
QJsonObject stepBack() QJsonObject stepBack();
{
if (m_index > 0) --m_index;
emit queueChanged();
return current();
}
bool canGoNext() const bool canGoNext() const;
{
return !m_playNext.isEmpty() || (m_index + 1 < m_queue.size());
}
bool canGoPrev() const { return m_index > 0; } bool canGoPrev() const { return m_index > 0; }
// ---- Index lookup ---- // ---- Index lookup ----
/// Set the current position by track id (after user double-clicks a row). /// Set the current position by track id (after user double-clicks a row).
void setCurrentById(qint64 id) void setCurrentById(qint64 id);
{
m_playNext.clear();
for (int i = 0; i < m_queue.size(); ++i) {
if (static_cast<qint64>(m_queue[i]["id"].toDouble()) == id) {
m_index = i;
emit queueChanged();
return;
}
}
}
// ---- Accessors for queue panel ---- // ---- Accessors for queue panel ----
QVector<QJsonObject> upcomingTracks(int maxCount = 200) const QVector<QJsonObject> upcomingTracks(int maxCount = 200) const;
{
QVector<QJsonObject> result;
result.append(m_playNext);
for (int i = m_index + 1; i < m_queue.size() && result.size() < maxCount; ++i)
result.append(m_queue.at(i));
return result;
}
int playNextCount() const { return m_playNext.size(); } int playNextCount() const { return m_playNext.size(); }
int totalSize() const { return m_playNext.size() + m_queue.size(); } int totalSize() const { return m_playNext.size() + m_queue.size(); }
int currentIndex() const { return m_index; } int currentIndex() const { return m_index; }
/// Skip to upcoming[upcomingIndex]: removes everything before it, pops and returns it. /// Skip to upcoming[upcomingIndex]: removes everything before it, pops and returns it.
QJsonObject skipToUpcoming(int upcomingIndex) QJsonObject skipToUpcoming(int upcomingIndex);
{
// Remove items 0..upcomingIndex-1 from the front of upcoming
for (int i = 0; i < upcomingIndex; ++i) {
if (!m_playNext.isEmpty())
m_playNext.removeFirst();
else if (m_index + 1 < m_queue.size())
++m_index;
}
// Pop and return the target (now at upcoming[0])
if (!m_playNext.isEmpty()) {
const QJsonObject t = m_playNext.takeFirst();
emit queueChanged();
return t;
}
if (m_index + 1 < m_queue.size()) {
++m_index;
emit queueChanged();
return m_queue.at(m_index);
}
emit queueChanged();
return {};
}
/// Replace the upcoming list with a new order (used after drag-reorder in UI). /// Replace the upcoming list with a new order (used after drag-reorder in UI).
void setUpcomingOrder(const QVector<QJsonObject> &newOrder) void setUpcomingOrder(const QVector<QJsonObject> &newOrder);
{
m_playNext = newOrder; /// Append tracks to the main queue tail (autoplay/discovery).
m_queue.resize(m_index + 1); // drop old main-queue tail void appendToContext(const QJsonArray &tracks);
emit queueChanged();
}
/// Move an upcoming item (by its index in upcomingTracks()) to the front of playNext. /// Move an upcoming item (by its index in upcomingTracks()) to the front of playNext.
void moveUpcomingToTop(int upcomingIndex) void moveUpcomingToTop(int upcomingIndex);
{
if (upcomingIndex < 0) return;
QJsonObject track;
if (upcomingIndex < m_playNext.size()) {
if (upcomingIndex == 0) return; // already at top
track = m_playNext.takeAt(upcomingIndex);
} else {
const int queueIdx = m_index + 1 + (upcomingIndex - m_playNext.size());
if (queueIdx >= m_queue.size()) return;
track = m_queue.takeAt(queueIdx);
}
m_playNext.prepend(track);
emit queueChanged();
}
signals: signals:
void queueChanged(); void queueChanged();
@@ -267,19 +100,5 @@ private:
int m_index = 0; int m_index = 0;
bool m_shuffle = false; bool m_shuffle = false;
void shuffleQueue(int keepAtFront) void shuffleQueue(int keepAtFront);
{
if (m_queue.isEmpty()) return;
// Keep the current track at index 0 of the remaining queue
if (keepAtFront >= 0 && keepAtFront < m_queue.size()) {
QJsonObject current = m_queue.takeAt(keepAtFront);
std::mt19937 rng(std::random_device{}());
std::shuffle(m_queue.begin(), m_queue.end(), rng);
m_queue.prepend(current);
} else {
std::mt19937 rng(std::random_device{}());
std::shuffle(m_queue.begin(), m_queue.end(), rng);
}
m_index = 0;
}
}; };

32
src/util/colors.hpp Normal file
View File

@@ -0,0 +1,32 @@
#pragma once
#include <QColor>
namespace Colors {
// Brand accents
inline const QColor QobuzOrange{0xFF, 0xB2, 0x32};
inline const QColor QobuzBlue {0x46, 0xB3, 0xEE};
// Badge / indicator colors used in tree-view item foregrounds
inline const QColor BadgeGreen {QStringLiteral("#2FA84F")};
inline const QColor BadgeBlue {QStringLiteral("#2B7CD3")};
inline const QColor BadgeGray {QStringLiteral("#8E8E93")};
// Text
inline const QColor LightText {0xe8, 0xe8, 0xe8};
inline const QColor SubduedText {0xaa, 0xaa, 0xaa};
inline const QColor PlaceholderText{0x66, 0x66, 0x66};
inline const QColor DisabledText {0x55, 0x55, 0x55};
// Surfaces / backgrounds
inline const QColor WindowBg {0x19, 0x19, 0x19};
inline const QColor BaseBg {0x14, 0x14, 0x14};
inline const QColor AlternateBaseBg{0x1e, 0x1e, 0x1e};
inline const QColor ButtonSurface {0x2a, 0x2a, 0x2a};
inline const QColor ContextBg {0x1a, 0x1a, 0x1a};
inline const QColor MidSurface {0x2f, 0x2f, 0x2f};
inline const QColor DarkSurface {0x0e, 0x0e, 0x0e};
inline const QColor HighlightedFg {0x10, 0x10, 0x10};
} // namespace Colors

View File

@@ -24,6 +24,7 @@ namespace Icon
inline QIcon previous() { return get("media-skip-backward"); } inline QIcon previous() { return get("media-skip-backward"); }
inline QIcon shuffle() { return get("media-playlist-shuffle"); } inline QIcon shuffle() { return get("media-playlist-shuffle"); }
inline QIcon repeat() { return get("media-playlist-repeat"); } inline QIcon repeat() { return get("media-playlist-repeat"); }
inline QIcon autoplay() { return get("media-track-show-active"); }
// Volume // Volume
inline QIcon volumeHigh() { return get("audio-volume-high"); } inline QIcon volumeHigh() { return get("audio-volume-high"); }

View File

@@ -41,6 +41,9 @@ public:
bool gaplessEnabled() const { return m_settings.value("playback/gapless", false).toBool(); } bool gaplessEnabled() const { return m_settings.value("playback/gapless", false).toBool(); }
void setGaplessEnabled(bool v) { m_settings.setValue("playback/gapless", v); } void setGaplessEnabled(bool v) { m_settings.setValue("playback/gapless", v); }
bool autoplayEnabled() const { return m_settings.value("playback/autoplay", false).toBool(); }
void setAutoplayEnabled(bool v) { m_settings.setValue("playback/autoplay", v); }
// --- Last.fm --- // --- Last.fm ---
bool lastFmEnabled() const { return m_settings.value("lastfm/enabled", false).toBool(); } bool lastFmEnabled() const { return m_settings.value("lastfm/enabled", false).toBool(); }
void setLastFmEnabled(bool v) { m_settings.setValue("lastfm/enabled", v); } void setLastFmEnabled(bool v) { m_settings.setValue("lastfm/enabled", v); }

View File

@@ -1,5 +1,7 @@
#pragma once #pragma once
#include "../util/colors.hpp"
#include <QTreeWidget> #include <QTreeWidget>
#include <QTreeWidgetItem> #include <QTreeWidgetItem>
#include <QHeaderView> #include <QHeaderView>
@@ -89,7 +91,7 @@ public:
auto *item = new QTreeWidgetItem(this); auto *item = new QTreeWidgetItem(this);
if (hiRes) { if (hiRes) {
item->setText(0, QStringLiteral("H")); item->setText(0, QStringLiteral("H"));
item->setForeground(0, QColor(QStringLiteral("#FFB232"))); item->setForeground(0, Colors::QobuzOrange);
item->setFont(0, hiResFont); item->setFont(0, hiResFont);
item->setTextAlignment(0, Qt::AlignCenter); item->setTextAlignment(0, Qt::AlignCenter);
} }

View File

@@ -1,6 +1,7 @@
#pragma once #pragma once
#include "../../backend/qobuzbackend.hpp" #include "../../backend/qobuzbackend.hpp"
#include "../../util/colors.hpp"
#include <QDockWidget> #include <QDockWidget>
#include <QWidget> #include <QWidget>
@@ -32,11 +33,11 @@ namespace Context
{ {
QPainter p(this); QPainter p(this);
if (m_pix.isNull()) { if (m_pix.isNull()) {
p.fillRect(rect(), QColor(0x1a, 0x1a, 0x1a)); p.fillRect(rect(), Colors::ContextBg);
return; return;
} }
const QPixmap scaled = m_pix.scaled(size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); const QPixmap scaled = m_pix.scaled(size(), Qt::KeepAspectRatio, Qt::SmoothTransformation);
p.fillRect(rect(), QColor(0x1a, 0x1a, 0x1a)); p.fillRect(rect(), Colors::ContextBg);
p.drawPixmap((width() - scaled.width()) / 2, p.drawPixmap((width() - scaled.width()) / 2,
(height() - scaled.height()) / 2, (height() - scaled.height()) / 2,
scaled); scaled);

View File

@@ -1,15 +1,27 @@
#include "genrebrowser.hpp" #include "genrebrowser.hpp"
#include "../util/colors.hpp"
#include <QAction> #include <QAction>
#include <QDialog>
#include <QDialogButtonBox>
#include <QHeaderView>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLabel> #include <QLabel>
#include <QListWidget>
#include <QMenu> #include <QMenu>
#include <QPushButton>
#include <QScrollBar>
#include <QSignalBlocker>
#include <QTimer>
#include <QTreeWidgetItem> #include <QTreeWidgetItem>
#include <QVBoxLayout> #include <QVBoxLayout>
GenreBrowserView::GenreBrowserView(QobuzBackend *backend, QWidget *parent) #include <algorithm>
GenreBrowserView::GenreBrowserView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
: QWidget(parent) : QWidget(parent)
, m_backend(backend) , m_backend(backend)
, m_queue(queue)
{ {
auto *layout = new QVBoxLayout(this); auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0); layout->setContentsMargins(0, 0, 0, 0);
@@ -18,42 +30,178 @@ GenreBrowserView::GenreBrowserView(QobuzBackend *backend, QWidget *parent)
auto *topBar = new QWidget(this); auto *topBar = new QWidget(this);
auto *topLayout = new QHBoxLayout(topBar); auto *topLayout = new QHBoxLayout(topBar);
topLayout->setContentsMargins(8, 6, 8, 6); topLayout->setContentsMargins(8, 6, 8, 6);
topLayout->setSpacing(6);
topLayout->addWidget(new QLabel(tr("Genre:"), this)); QFont topFont = topBar->font();
if (topFont.pointSize() > 0)
topFont.setPointSize(topFont.pointSize() + 1);
static constexpr int controlHeight = 30;
m_browseLabel = new QLabel(tr("Show:"), this);
m_browseLabel->setFont(topFont);
topLayout->addWidget(m_browseLabel);
m_kindCombo = new QComboBox(this);
m_kindCombo->addItem(tr("Albums"), QStringLiteral("albums"));
m_kindCombo->addItem(tr("Playlists"), QStringLiteral("playlists"));
m_kindCombo->setFont(topFont);
m_kindCombo->setMinimumWidth(110);
m_kindCombo->setFixedHeight(controlHeight);
topLayout->addWidget(m_kindCombo);
m_gapAfterKind = new QWidget(this);
m_gapAfterKind->setFixedWidth(6);
topLayout->addWidget(m_gapAfterKind);
m_genreLabel = new QLabel(tr("Genre:"), this);
m_genreLabel->setFont(topFont);
topLayout->addWidget(m_genreLabel);
m_genreCombo = new QComboBox(this); m_genreCombo = new QComboBox(this);
m_genreCombo->setMinimumWidth(160); m_genreCombo->setFont(topFont);
m_genreCombo->setMinimumWidth(180);
m_genreCombo->setFixedHeight(controlHeight);
topLayout->addWidget(m_genreCombo); topLayout->addWidget(m_genreCombo);
topLayout->addSpacing(16); m_gapAfterGenre = new QWidget(this);
m_gapAfterGenre->setFixedWidth(10);
topLayout->addWidget(m_gapAfterGenre);
topLayout->addWidget(new QLabel(tr("Type:"), this)); m_typeLabel = new QLabel(tr("Type:"), this);
m_typeLabel->setFont(topFont);
topLayout->addWidget(m_typeLabel);
m_typeCombo = new QComboBox(this); m_typeCombo = new QComboBox(this);
m_typeCombo->addItem(tr("New Releases"), QStringLiteral("new-releases")); m_typeCombo->setFont(topFont);
m_typeCombo->addItem(tr("Best Sellers"), QStringLiteral("best-sellers")); m_typeCombo->setMinimumWidth(180);
m_typeCombo->addItem(tr("Most Streamed"), QStringLiteral("most-streamed")); m_typeCombo->setFixedHeight(controlHeight);
m_typeCombo->addItem(tr("Editor Picks"), QStringLiteral("editor-picks"));
m_typeCombo->addItem(tr("Press Awards"), QStringLiteral("press-awards"));
topLayout->addWidget(m_typeCombo); topLayout->addWidget(m_typeCombo);
m_playlistSearchLabel = new QLabel(tr("Search:"), this);
m_playlistSearchLabel->setFont(topFont);
m_playlistSearchLabel->setVisible(false);
topLayout->addWidget(m_playlistSearchLabel);
m_playlistSearchBox = new QLineEdit(this);
m_playlistSearchBox->setFont(topFont);
m_playlistSearchBox->setPlaceholderText(tr("Search playlists..."));
m_playlistSearchBox->setClearButtonEnabled(true);
m_playlistSearchBox->setVisible(false);
m_playlistSearchBox->setMinimumWidth(220);
m_playlistSearchBox->setMaximumWidth(320);
m_playlistSearchBox->setFixedHeight(controlHeight);
topLayout->addWidget(m_playlistSearchBox);
m_playlistSearchBtn = new QPushButton(tr("Go"), this);
m_playlistSearchBtn->setFont(topFont);
m_playlistSearchBtn->setVisible(false);
m_playlistSearchBtn->setFixedHeight(controlHeight);
topLayout->addWidget(m_playlistSearchBtn);
m_deepShuffleBtn = new QPushButton(tr("⇄ Deep Shuffle"), this);
m_deepShuffleBtn->setFont(topFont);
m_deepShuffleBtn->setVisible(false);
m_deepShuffleBtn->setFixedHeight(controlHeight);
topLayout->addWidget(m_deepShuffleBtn);
topLayout->addStretch(); topLayout->addStretch();
layout->addWidget(topBar); layout->addWidget(topBar);
m_resultsStack = new QStackedWidget(this);
m_albumList = new AlbumListView(this); m_albumList = new AlbumListView(this);
m_albumList->setContextMenuPolicy(Qt::CustomContextMenu); m_albumList->setContextMenuPolicy(Qt::CustomContextMenu);
layout->addWidget(m_albumList, 1);
m_playlistList = new QTreeWidget(this);
m_playlistList->setColumnCount(4);
m_playlistList->setHeaderLabels({tr(""), tr("Playlist"), tr("Owner"), tr("Tracks")});
m_playlistList->setRootIsDecorated(false);
m_playlistList->setAlternatingRowColors(true);
m_playlistList->setSelectionBehavior(QAbstractItemView::SelectRows);
m_playlistList->setSortingEnabled(true);
m_playlistList->setContextMenuPolicy(Qt::CustomContextMenu);
m_playlistList->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
m_playlistList->header()->setSectionResizeMode(1, QHeaderView::Stretch);
m_playlistList->header()->setSectionResizeMode(2, QHeaderView::Stretch);
m_playlistList->header()->setSectionResizeMode(3, QHeaderView::ResizeToContents);
m_playlistList->header()->setStretchLastSection(false);
auto *playlistPage = new QWidget(this);
auto *playlistPageLayout = new QVBoxLayout(playlistPage);
playlistPageLayout->setContentsMargins(0, 0, 0, 0);
playlistPageLayout->setSpacing(0);
playlistPageLayout->addWidget(m_playlistList, 1);
m_loadMorePlaylistsBtn = new QPushButton(tr("Load more playlists…"), this);
m_loadMorePlaylistsBtn->hide();
playlistPageLayout->addWidget(m_loadMorePlaylistsBtn);
m_resultsStack->addWidget(m_albumList);
m_resultsStack->addWidget(playlistPage);
layout->addWidget(m_resultsStack, 1);
connect(m_backend, &QobuzBackend::genresLoaded, connect(m_backend, &QobuzBackend::genresLoaded,
this, &GenreBrowserView::onGenresLoaded); this, &GenreBrowserView::onGenresLoaded);
connect(m_backend, &QobuzBackend::featuredAlbumsLoaded, connect(m_backend, &QobuzBackend::featuredAlbumsLoaded,
this, &GenreBrowserView::onFeaturedAlbumsLoaded); this, &GenreBrowserView::onFeaturedAlbumsLoaded);
connect(m_backend, &QobuzBackend::featuredPlaylistsLoaded,
this, &GenreBrowserView::onFeaturedPlaylistsLoaded);
connect(m_backend, &QobuzBackend::discoverPlaylistsLoaded,
this, &GenreBrowserView::onDiscoverPlaylistsLoaded);
connect(m_backend, &QobuzBackend::playlistSearchLoaded,
this, &GenreBrowserView::onPlaylistSearchLoaded);
connect(m_genreCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), connect(m_genreCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &GenreBrowserView::onSelectionChanged); this, [this](int index) {
const QString data = m_genreCombo->itemData(index).toString();
if (data == QStringLiteral("__multi__")) {
if (!chooseMultiGenres()) {
const QSignalBlocker blocker(m_genreCombo);
m_genreCombo->setCurrentIndex(m_lastGenreComboIndex);
return;
}
m_lastGenreComboIndex = index;
updateMultiGenreLabel();
} else {
m_lastGenreComboIndex = index;
if (data == QStringLiteral("__all__")) {
m_multiGenreIds.clear();
updateMultiGenreLabel();
}
}
onSelectionChanged();
});
connect(m_kindCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, [this](int) {
refreshGenreTypeChoices();
onSelectionChanged();
});
connect(m_typeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), connect(m_typeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &GenreBrowserView::onSelectionChanged); this, &GenreBrowserView::onSelectionChanged);
connect(m_playlistSearchBox, &QLineEdit::returnPressed,
this, &GenreBrowserView::onSelectionChanged);
connect(m_playlistSearchBtn, &QPushButton::clicked,
this, &GenreBrowserView::onSelectionChanged);
connect(m_deepShuffleBtn, &QPushButton::clicked,
this, &GenreBrowserView::onDeepShuffleClicked);
connect(m_albumList, &AlbumListView::albumSelected, connect(m_albumList, &AlbumListView::albumSelected,
this, &GenreBrowserView::albumSelected); this, &GenreBrowserView::albumSelected);
connect(m_albumList, &QTreeWidget::customContextMenuRequested, connect(m_albumList, &QTreeWidget::customContextMenuRequested,
this, &GenreBrowserView::onAlbumContextMenu); this, &GenreBrowserView::onAlbumContextMenu);
connect(m_playlistList, &QTreeWidget::itemDoubleClicked,
this, &GenreBrowserView::onPlaylistActivated);
connect(m_playlistList, &QTreeWidget::customContextMenuRequested,
this, &GenreBrowserView::onPlaylistContextMenu);
connect(m_albumList->verticalScrollBar(), &QScrollBar::valueChanged,
this, &GenreBrowserView::onAlbumScroll);
connect(m_playlistList->verticalScrollBar(), &QScrollBar::valueChanged,
this, &GenreBrowserView::onPlaylistScroll);
connect(m_loadMorePlaylistsBtn, &QPushButton::clicked, this, [this] {
m_loadMorePlaylistsBtn->hide();
requestPlaylistsPage(m_lastPlaylistGenreIds, m_lastPlaylistType,
m_lastPlaylistTags, m_lastPlaylistQuery,
m_playlistOffset, true);
});
m_kindCombo->setCurrentIndex(0);
refreshModeUi();
} }
void GenreBrowserView::ensureGenresLoaded() void GenreBrowserView::ensureGenresLoaded()
@@ -62,11 +210,156 @@ void GenreBrowserView::ensureGenresLoaded()
m_backend->getGenres(); m_backend->getGenres();
} }
void GenreBrowserView::setBrowseMode(BrowseMode mode)
{
if (m_mode == mode)
return;
m_mode = mode;
refreshModeUi();
onSelectionChanged();
}
void GenreBrowserView::refreshModeUi()
{
const bool genreMode = (m_mode == BrowseMode::Genres);
m_browseLabel->setVisible(genreMode);
m_kindCombo->setVisible(genreMode);
m_gapAfterKind->setVisible(genreMode);
m_genreLabel->setVisible(genreMode);
m_genreCombo->setVisible(genreMode);
m_gapAfterGenre->setVisible(genreMode);
m_typeLabel->setVisible(genreMode);
m_typeCombo->setVisible(genreMode);
if (genreMode) {
m_playlistSearchBox->setVisible(false);
m_playlistSearchLabel->setVisible(false);
m_playlistSearchBtn->setVisible(false);
m_deepShuffleBtn->setVisible(m_kindCombo->currentData().toString() == QStringLiteral("albums"));
refreshGenreTypeChoices();
return;
}
m_typeCombo->blockSignals(true);
m_typeCombo->clear();
m_typeCombo->addItem(tr("Search"), QStringLiteral("search"));
m_typeCombo->blockSignals(false);
m_playlistSearchLabel->setVisible(true);
m_playlistSearchBox->setVisible(true);
m_playlistSearchBtn->setVisible(true);
m_deepShuffleBtn->setVisible(false);
m_resultsStack->setCurrentIndex(1);
}
void GenreBrowserView::refreshGenreTypeChoices()
{
m_typeCombo->blockSignals(true);
m_typeCombo->clear();
const QString kind = m_kindCombo->currentData().toString();
if (kind == QStringLiteral("playlists")) {
m_typeCombo->addItem(tr("Featured: Last Created"), QStringLiteral("last-created"));
m_typeCombo->addItem(tr("Discover: New"), QStringLiteral("discover-new"));
m_typeCombo->addItem(tr("Discover: Hi-Res"), QStringLiteral("discover-hires"));
m_typeCombo->addItem(tr("Discover: Focus"), QStringLiteral("discover-focus"));
m_typeCombo->addItem(tr("Discover: Qobuz Digs"), QStringLiteral("discover-qobuzdigs"));
m_resultsStack->setCurrentIndex(1);
m_deepShuffleBtn->setVisible(false);
} else {
m_typeCombo->addItem(tr("New Releases"), QStringLiteral("new-releases"));
m_typeCombo->addItem(tr("Best Sellers"), QStringLiteral("best-sellers"));
m_typeCombo->addItem(tr("Most Streamed"), QStringLiteral("most-streamed"));
m_typeCombo->addItem(tr("Editor Picks"), QStringLiteral("editor-picks"));
m_typeCombo->addItem(tr("Press Awards"), QStringLiteral("press-awards"));
m_resultsStack->setCurrentIndex(0);
m_deepShuffleBtn->setVisible(m_mode == BrowseMode::Genres);
}
m_typeCombo->blockSignals(false);
}
QString GenreBrowserView::currentGenreIds() const
{
const QString data = m_genreCombo->currentData().toString();
if (data == QStringLiteral("__all__"))
return QString();
if (data == QStringLiteral("__multi__")) {
if (m_multiGenreIds.isEmpty())
return QString();
QList<qint64> ids = m_multiGenreIds.values();
std::sort(ids.begin(), ids.end());
QStringList out;
out.reserve(ids.size());
for (qint64 id : ids)
out.push_back(QString::number(id));
return out.join(QLatin1Char(','));
}
return data;
}
bool GenreBrowserView::chooseMultiGenres()
{
QDialog dlg(this);
dlg.setWindowTitle(tr("Select genres"));
dlg.resize(320, 420);
auto *layout = new QVBoxLayout(&dlg);
auto *list = new QListWidget(&dlg);
list->setAlternatingRowColors(true);
layout->addWidget(list, 1);
for (int i = 0; i < m_genreCombo->count(); ++i) {
const QString data = m_genreCombo->itemData(i).toString();
if (data == QStringLiteral("__all__") || data == QStringLiteral("__multi__"))
continue;
auto *item = new QListWidgetItem(m_genreCombo->itemText(i), list);
item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
const qint64 id = data.toLongLong();
item->setData(Qt::UserRole, id);
item->setCheckState(m_multiGenreIds.contains(id) ? Qt::Checked : Qt::Unchecked);
}
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dlg);
layout->addWidget(buttons);
connect(buttons, &QDialogButtonBox::accepted, &dlg, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject);
if (dlg.exec() != QDialog::Accepted)
return false;
m_multiGenreIds.clear();
for (int i = 0; i < list->count(); ++i) {
QListWidgetItem *item = list->item(i);
if (item->checkState() == Qt::Checked)
m_multiGenreIds.insert(item->data(Qt::UserRole).toLongLong());
}
return true;
}
void GenreBrowserView::updateMultiGenreLabel()
{
const int multiIndex = m_genreCombo->count() - 1;
if (multiIndex < 0)
return;
const QString text = m_multiGenreIds.isEmpty()
? tr("Multiple...")
: tr("Multiple (%1)").arg(m_multiGenreIds.size());
m_genreCombo->setItemText(multiIndex, text);
}
void GenreBrowserView::onGenresLoaded(const QJsonObject &result) void GenreBrowserView::onGenresLoaded(const QJsonObject &result)
{ {
m_genresLoaded = true; m_genresLoaded = true;
m_genreCombo->blockSignals(true); m_genreCombo->blockSignals(true);
m_genreCombo->clear(); m_genreCombo->clear();
m_genreCombo->addItem(tr("All genres"), QStringLiteral("__all__"));
const QJsonArray items = result["items"].toArray(); const QJsonArray items = result["items"].toArray();
for (const auto &value : items) { for (const auto &value : items) {
@@ -76,23 +369,350 @@ void GenreBrowserView::onGenresLoaded(const QJsonObject &result)
static_cast<qint64>(genre["id"].toDouble())); static_cast<qint64>(genre["id"].toDouble()));
} }
m_genreCombo->addItem(tr("Multiple..."), QStringLiteral("__multi__"));
updateMultiGenreLabel();
m_lastGenreComboIndex = 0;
m_genreCombo->setCurrentIndex(0);
m_genreCombo->blockSignals(false); m_genreCombo->blockSignals(false);
onSelectionChanged(); onSelectionChanged();
} }
void GenreBrowserView::onFeaturedAlbumsLoaded(const QJsonObject &result) void GenreBrowserView::onFeaturedAlbumsLoaded(const QJsonObject &result)
{ {
m_albumList->setAlbums(result["items"].toArray()); m_resultsStack->setCurrentIndex(0);
const QString genreIds = result["genre_ids"].toString();
const QString type = result["type"].toString();
const int offset = result["offset"].toInt();
if (genreIds != m_lastAlbumGenreIds || type != m_lastAlbumType)
return;
const QJsonArray items = result["items"].toArray();
if (offset <= 0)
m_albumList->setAlbums(items);
else
m_albumList->addAlbums(items);
m_albumTotal = result["total"].toInt();
m_albumOffset = offset + items.size();
if (items.isEmpty())
m_albumTotal = m_albumOffset;
m_loadingAlbums = false;
if (m_collectAlbumsForDeepShuffle) {
if (m_albumOffset < m_albumTotal) {
requestAlbumsPage(m_lastAlbumGenreIds, m_lastAlbumType, m_albumOffset, true);
return;
}
m_collectAlbumsForDeepShuffle = false;
startDeepShuffleFromLoadedAlbums();
return;
}
// If the viewport is not scrollable yet, eagerly fetch more pages.
// Deferred: the scrollbar maximum isn't updated until after layout runs.
QTimer::singleShot(0, this, [this] {
QScrollBar *bar = m_albumList->verticalScrollBar();
if (bar && bar->maximum() == 0 && m_albumOffset < m_albumTotal)
requestAlbumsPage(m_lastAlbumGenreIds, m_lastAlbumType, m_albumOffset, true);
});
}
void GenreBrowserView::onFeaturedPlaylistsLoaded(const QJsonObject &result)
{
m_resultsStack->setCurrentIndex(1);
const QString genreIds = result["genre_ids"].toString();
const QString type = result["type"].toString();
const int offset = result["offset"].toInt();
if (genreIds != m_lastPlaylistGenreIds || type != m_lastPlaylistType)
return;
const QJsonArray items = result["items"].toArray();
setPlaylistItems(items, offset > 0);
m_playlistTotal = result["total"].toInt();
m_playlistOffset = offset + items.size();
if (items.isEmpty())
m_playlistTotal = m_playlistOffset;
m_loadingPlaylists = false;
QTimer::singleShot(0, this, [this] {
QScrollBar *bar = m_playlistList->verticalScrollBar();
if (bar && bar->maximum() == 0 && m_playlistOffset < m_playlistTotal)
requestPlaylistsPage(m_lastPlaylistGenreIds, m_lastPlaylistType, m_lastPlaylistTags, m_lastPlaylistQuery, m_playlistOffset, true);
});
}
void GenreBrowserView::onDiscoverPlaylistsLoaded(const QJsonObject &result)
{
m_resultsStack->setCurrentIndex(1);
const QString genreIds = result["genre_ids"].toString();
const QString tags = result["tags"].toString();
const int offset = result["offset"].toInt();
if (genreIds != m_lastPlaylistGenreIds || tags != m_lastPlaylistTags)
return;
const QJsonArray items = result["items"].toArray();
setPlaylistItems(items, offset > 0);
m_playlistTotal = result["total"].toInt();
m_playlistOffset = offset + items.size();
if (items.isEmpty())
m_playlistTotal = m_playlistOffset;
m_loadingPlaylists = false;
QTimer::singleShot(0, this, [this] {
QScrollBar *bar = m_playlistList->verticalScrollBar();
if (bar && bar->maximum() == 0 && m_playlistOffset < m_playlistTotal)
requestPlaylistsPage(m_lastPlaylistGenreIds, m_lastPlaylistType, m_lastPlaylistTags, m_lastPlaylistQuery, m_playlistOffset, true);
});
}
void GenreBrowserView::onPlaylistSearchLoaded(const QJsonObject &result)
{
m_resultsStack->setCurrentIndex(1);
const QString query = result["query"].toString();
const int offset = result["offset"].toInt();
if (query != m_lastPlaylistQuery)
return;
const QJsonArray items = result["items"].toArray();
setPlaylistItems(items, offset > 0);
m_playlistTotal = result["total"].toInt();
m_playlistOffset = offset + items.size();
if (items.isEmpty())
m_playlistTotal = m_playlistOffset;
m_loadingPlaylists = false;
// Eagerly fill the viewport, then switch to a manual "Load more" button.
if (m_playlistOffset >= m_playlistTotal) {
m_loadMorePlaylistsBtn->hide();
m_searchViewportFilled = true;
} else if (!m_searchViewportFilled) {
QTimer::singleShot(0, this, [this] {
QScrollBar *bar = m_playlistList->verticalScrollBar();
if (bar && bar->maximum() == 0 && m_playlistOffset < m_playlistTotal) {
requestPlaylistsPage(m_lastPlaylistGenreIds, m_lastPlaylistType,
m_lastPlaylistTags, m_lastPlaylistQuery,
m_playlistOffset, true);
} else {
m_searchViewportFilled = true;
m_loadMorePlaylistsBtn->setVisible(m_playlistOffset < m_playlistTotal);
}
});
} else {
m_loadMorePlaylistsBtn->setVisible(true);
}
} }
void GenreBrowserView::onSelectionChanged() void GenreBrowserView::onSelectionChanged()
{ {
m_collectAlbumsForDeepShuffle = false;
if (m_mode == BrowseMode::PlaylistSearch) {
m_resultsStack->setCurrentIndex(1);
m_playlistSearchLabel->setVisible(true);
m_playlistSearchBox->setVisible(true);
m_playlistSearchBtn->setVisible(true);
m_deepShuffleBtn->setVisible(false);
m_deepShuffleBtn->setEnabled(true);
m_deepShuffleBtn->setText(tr("⇄ Deep Shuffle"));
const QString query = m_playlistSearchBox->text().trimmed();
if (query.size() < 2) {
m_playlistList->clear();
m_playlistOffset = 0;
m_playlistTotal = 0;
m_loadingPlaylists = false;
} else {
requestPlaylistsPage(QString(), QStringLiteral("search"), QString(), query, 0, false);
}
return;
}
if (m_genreCombo->count() == 0) if (m_genreCombo->count() == 0)
return; return;
const qint64 genreId = m_genreCombo->currentData().toLongLong(); const QString genreIds = currentGenreIds();
const QString type = m_typeCombo->currentData().toString(); const QString type = m_typeCombo->currentData().toString();
m_backend->getFeaturedAlbums(genreId, type, 50, 0); const QString kind = m_kindCombo->currentData().toString();
m_playlistSearchLabel->setVisible(false);
m_playlistSearchBox->setVisible(false);
m_playlistSearchBtn->setVisible(false);
if (kind == QStringLiteral("playlists")) {
m_resultsStack->setCurrentIndex(1);
m_deepShuffleBtn->setVisible(false);
m_deepShuffleBtn->setEnabled(true);
m_deepShuffleBtn->setText(tr("⇄ Deep Shuffle"));
if (type == QStringLiteral("discover-new"))
requestPlaylistsPage(genreIds, type, QStringLiteral("new"), QString(), 0, false);
else if (type == QStringLiteral("discover-hires"))
requestPlaylistsPage(genreIds, type, QStringLiteral("hi-res"), QString(), 0, false);
else if (type == QStringLiteral("discover-focus"))
requestPlaylistsPage(genreIds, type, QStringLiteral("focus"), QString(), 0, false);
else if (type == QStringLiteral("discover-qobuzdigs"))
requestPlaylistsPage(genreIds, type, QStringLiteral("qobuzdigs"), QString(), 0, false);
else
requestPlaylistsPage(genreIds, type, QString(), QString(), 0, false);
} else {
m_resultsStack->setCurrentIndex(0);
m_deepShuffleBtn->setVisible(m_mode == BrowseMode::Genres);
m_deepShuffleBtn->setEnabled(true);
m_deepShuffleBtn->setText(tr("⇄ Deep Shuffle"));
requestAlbumsPage(genreIds, type, 0, false);
}
}
QStringList GenreBrowserView::currentAlbumIds() const
{
QStringList ids;
for (int i = 0; i < m_albumList->topLevelItemCount(); ++i) {
const QString id = m_albumList->topLevelItem(i)->data(1, Qt::UserRole).toString();
if (!id.isEmpty())
ids.push_back(id);
}
return ids;
}
void GenreBrowserView::startDeepShuffleFromLoadedAlbums()
{
const QStringList albumIds = currentAlbumIds();
if (albumIds.isEmpty()) {
m_deepShuffleBtn->setEnabled(true);
m_deepShuffleBtn->setText(tr("⇄ Deep Shuffle"));
return;
}
m_waitingDeepShuffle = true;
m_deepShuffleBtn->setEnabled(false);
m_deepShuffleBtn->setText(tr("Loading…"));
m_backend->getAlbumsTracks(albumIds);
}
void GenreBrowserView::requestAlbumsPage(const QString &genreIds, const QString &type, int offset, bool append)
{
if (append && m_loadingAlbums)
return;
if (!append) {
m_loadingAlbums = false;
m_albumOffset = 0;
m_albumTotal = 0;
}
m_lastAlbumGenreIds = genreIds;
m_lastAlbumType = type;
m_loadingAlbums = true;
m_backend->getFeaturedAlbums(genreIds, type, 50, static_cast<quint32>(offset));
}
void GenreBrowserView::requestPlaylistsPage(const QString &genreIds, const QString &type, const QString &tags, const QString &query, int offset, bool append)
{
if (append && m_loadingPlaylists)
return;
if (!append) {
m_loadingPlaylists = false;
m_playlistOffset = 0;
m_playlistTotal = 0;
m_loadMorePlaylistsBtn->hide();
if (type == QStringLiteral("search"))
m_searchViewportFilled = false;
}
m_lastPlaylistGenreIds = genreIds;
m_lastPlaylistType = type;
m_lastPlaylistTags = tags;
m_lastPlaylistQuery = query;
m_loadingPlaylists = true;
if (type == QStringLiteral("search")) {
m_backend->searchPlaylists(query, 25, static_cast<quint32>(offset));
} else if (type.startsWith(QStringLiteral("discover-"))) {
m_backend->discoverPlaylists(genreIds, tags, 25, static_cast<quint32>(offset));
} else {
m_backend->getFeaturedPlaylists(genreIds, type, 25, static_cast<quint32>(offset));
}
}
void GenreBrowserView::onAlbumScroll(int value)
{
if (m_mode != BrowseMode::Genres)
return;
if (m_kindCombo->currentData().toString() != QStringLiteral("albums"))
return;
if (m_loadingAlbums)
return;
if (m_albumOffset >= m_albumTotal)
return;
QScrollBar *bar = m_albumList->verticalScrollBar();
if (!bar || value < (bar->maximum() - 12))
return;
requestAlbumsPage(m_lastAlbumGenreIds, m_lastAlbumType, m_albumOffset, true);
}
void GenreBrowserView::onPlaylistScroll(int value)
{
// Search results use a manual "Load more" button instead of infinite scroll.
if (m_lastPlaylistType == QStringLiteral("search"))
return;
if (m_loadingPlaylists)
return;
if (m_playlistOffset >= m_playlistTotal)
return;
QScrollBar *bar = m_playlistList->verticalScrollBar();
if (!bar || value < (bar->maximum() - 12))
return;
requestPlaylistsPage(
m_lastPlaylistGenreIds,
m_lastPlaylistType,
m_lastPlaylistTags,
m_lastPlaylistQuery,
m_playlistOffset,
true);
}
void GenreBrowserView::onDeepShuffleClicked()
{
m_deepShuffleBtn->setEnabled(false);
m_deepShuffleBtn->setText(tr("Loading…"));
if (m_loadingAlbums) {
m_collectAlbumsForDeepShuffle = true;
return;
}
if (m_albumOffset < m_albumTotal) {
m_collectAlbumsForDeepShuffle = true;
requestAlbumsPage(m_lastAlbumGenreIds, m_lastAlbumType, m_albumOffset, true);
return;
}
startDeepShuffleFromLoadedAlbums();
}
bool GenreBrowserView::tryHandleDeepShuffleTracks(const QJsonArray &tracks)
{
if (!m_waitingDeepShuffle)
return false;
m_waitingDeepShuffle = false;
m_collectAlbumsForDeepShuffle = false;
m_deepShuffleBtn->setEnabled(true);
m_deepShuffleBtn->setText(tr("⇄ Deep Shuffle"));
if (tracks.isEmpty())
return true;
m_queue->setContext(tracks, 0);
m_queue->shuffleNow();
const QJsonObject first = m_queue->current();
const qint64 id = static_cast<qint64>(first["id"].toDouble());
if (id > 0)
emit playTrackRequested(id);
return true;
} }
void GenreBrowserView::onAlbumContextMenu(const QPoint &pos) void GenreBrowserView::onAlbumContextMenu(const QPoint &pos)
@@ -103,16 +723,28 @@ void GenreBrowserView::onAlbumContextMenu(const QPoint &pos)
const QString albumId = item->data(1, Qt::UserRole).toString(); const QString albumId = item->data(1, Qt::UserRole).toString();
const qint64 artistId = item->data(2, Qt::UserRole).toLongLong(); const qint64 artistId = item->data(2, Qt::UserRole).toLongLong();
const QString albumTitle = item->text(1);
const QString artistName = item->text(2);
QMenu menu(this); QMenu menu(this);
auto *openAlbum = menu.addAction(tr("Open Album")); auto *openAlbum = menu.addAction(
QIcon(":/res/icons/view-media-album-cover.svg"),
tr("Open album: %1").arg(QString(albumTitle).replace(QLatin1Char('&'), QStringLiteral("&&"))));
connect(openAlbum, &QAction::triggered, this, [this, albumId] { connect(openAlbum, &QAction::triggered, this, [this, albumId] {
emit albumSelected(albumId); emit albumSelected(albumId);
}); });
auto *addFav = menu.addAction(QIcon(":/res/icons/starred-symbolic.svg"), tr("Add to favorites"));
connect(addFav, &QAction::triggered, this, [this, albumId] {
m_backend->addFavAlbum(albumId);
});
if (artistId > 0) { if (artistId > 0) {
auto *openArtist = menu.addAction(tr("Open Artist")); menu.addSeparator();
auto *openArtist = menu.addAction(
QIcon(":/res/icons/view-media-artist.svg"),
tr("Open artist: %1").arg(QString(artistName).replace(QLatin1Char('&'), QStringLiteral("&&"))));
connect(openArtist, &QAction::triggered, this, [this, artistId] { connect(openArtist, &QAction::triggered, this, [this, artistId] {
emit artistSelected(artistId); emit artistSelected(artistId);
}); });
@@ -120,3 +752,57 @@ void GenreBrowserView::onAlbumContextMenu(const QPoint &pos)
menu.exec(m_albumList->viewport()->mapToGlobal(pos)); menu.exec(m_albumList->viewport()->mapToGlobal(pos));
} }
void GenreBrowserView::onPlaylistActivated(QTreeWidgetItem *item, int)
{
if (!item)
return;
const qint64 playlistId = item->data(0, Qt::UserRole).toLongLong();
if (playlistId > 0)
emit playlistSelected(playlistId);
}
void GenreBrowserView::onPlaylistContextMenu(const QPoint &pos)
{
QTreeWidgetItem *item = m_playlistList->itemAt(pos);
if (!item)
return;
const qint64 playlistId = item->data(0, Qt::UserRole).toLongLong();
if (playlistId <= 0)
return;
QMenu menu(this);
auto *openPlaylist = menu.addAction(
QIcon(":/res/icons/view-media-playlist.svg"), tr("Open playlist"));
connect(openPlaylist, &QAction::triggered, this, [this, playlistId] {
emit playlistSelected(playlistId);
});
menu.exec(m_playlistList->viewport()->mapToGlobal(pos));
}
void GenreBrowserView::setPlaylistItems(const QJsonArray &items, bool append)
{
if (!append)
m_playlistList->clear();
QFont tagFont;
tagFont.setBold(true);
tagFont.setPointSizeF(tagFont.pointSizeF() * 0.85);
for (const auto &value : items) {
const QJsonObject playlist = value.toObject();
const qint64 playlistId = static_cast<qint64>(playlist["id"].toDouble());
const QString name = playlist["name"].toString();
const QString owner = playlist["owner"].toObject()["name"].toString();
const int tracksCount = playlist["tracks_count"].toInt();
auto *item = new QTreeWidgetItem(m_playlistList,
QStringList{QStringLiteral("P"), name, owner, tracksCount > 0 ? QString::number(tracksCount) : QString()});
item->setData(0, Qt::UserRole, playlistId);
item->setForeground(0, Colors::BadgeBlue);
item->setFont(0, tagFont);
item->setTextAlignment(0, Qt::AlignCenter);
}
}

View File

@@ -1,36 +1,104 @@
#pragma once #pragma once
#include "../backend/qobuzbackend.hpp" #include "../backend/qobuzbackend.hpp"
#include "../playqueue.hpp"
#include "albumlistview.hpp" #include "albumlistview.hpp"
#include <QComboBox> #include <QComboBox>
#include <QLabel>
#include <QLineEdit>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonObject> #include <QJsonObject>
#include <QSet>
#include <QPushButton>
#include <QStackedWidget>
#include <QTreeWidget>
#include <QWidget> #include <QWidget>
#include <QScrollBar>
class GenreBrowserView : public QWidget class GenreBrowserView : public QWidget
{ {
Q_OBJECT Q_OBJECT
public: public:
explicit GenreBrowserView(QobuzBackend *backend, QWidget *parent = nullptr); enum class BrowseMode {
Genres,
PlaylistSearch,
};
explicit GenreBrowserView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent = nullptr);
void ensureGenresLoaded(); void ensureGenresLoaded();
void setBrowseMode(BrowseMode mode);
bool tryHandleDeepShuffleTracks(const QJsonArray &tracks);
signals: signals:
void albumSelected(const QString &albumId); void albumSelected(const QString &albumId);
void artistSelected(qint64 artistId); void artistSelected(qint64 artistId);
void playlistSelected(qint64 playlistId);
void playTrackRequested(qint64 trackId);
private slots: private slots:
void onGenresLoaded(const QJsonObject &result); void onGenresLoaded(const QJsonObject &result);
void onFeaturedAlbumsLoaded(const QJsonObject &result); void onFeaturedAlbumsLoaded(const QJsonObject &result);
void onFeaturedPlaylistsLoaded(const QJsonObject &result);
void onDiscoverPlaylistsLoaded(const QJsonObject &result);
void onPlaylistSearchLoaded(const QJsonObject &result);
void onSelectionChanged(); void onSelectionChanged();
void onAlbumContextMenu(const QPoint &pos); void onAlbumContextMenu(const QPoint &pos);
void onPlaylistActivated(QTreeWidgetItem *item, int column);
void onPlaylistContextMenu(const QPoint &pos);
void onDeepShuffleClicked();
void onAlbumScroll(int value);
void onPlaylistScroll(int value);
private: private:
QobuzBackend *m_backend = nullptr; QobuzBackend *m_backend = nullptr;
PlayQueue *m_queue = nullptr;
QLabel *m_browseLabel = nullptr;
QLabel *m_genreLabel = nullptr;
QLabel *m_typeLabel = nullptr;
QLabel *m_playlistSearchLabel = nullptr;
QWidget *m_gapAfterKind = nullptr;
QWidget *m_gapAfterGenre = nullptr;
QComboBox *m_kindCombo = nullptr;
QComboBox *m_genreCombo = nullptr; QComboBox *m_genreCombo = nullptr;
QComboBox *m_typeCombo = nullptr; QComboBox *m_typeCombo = nullptr;
QLineEdit *m_playlistSearchBox = nullptr;
QPushButton *m_playlistSearchBtn = nullptr;
QPushButton *m_deepShuffleBtn = nullptr;
QStackedWidget *m_resultsStack = nullptr;
AlbumListView *m_albumList = nullptr; AlbumListView *m_albumList = nullptr;
QTreeWidget *m_playlistList = nullptr;
QPushButton *m_loadMorePlaylistsBtn = nullptr;
bool m_searchViewportFilled = false;
BrowseMode m_mode = BrowseMode::Genres;
bool m_genresLoaded = false; bool m_genresLoaded = false;
int m_lastGenreComboIndex = 0;
QSet<qint64> m_multiGenreIds;
bool m_waitingDeepShuffle = false;
bool m_collectAlbumsForDeepShuffle = false;
bool m_loadingAlbums = false;
bool m_loadingPlaylists = false;
int m_albumOffset = 0;
int m_albumTotal = 0;
int m_playlistOffset = 0;
int m_playlistTotal = 0;
QString m_lastAlbumGenreIds;
QString m_lastAlbumType;
QString m_lastPlaylistGenreIds;
QString m_lastPlaylistType;
QString m_lastPlaylistTags;
QString m_lastPlaylistQuery;
void refreshModeUi();
void refreshGenreTypeChoices();
QString currentGenreIds() const;
QStringList currentAlbumIds() const;
void startDeepShuffleFromLoadedAlbums();
void requestAlbumsPage(const QString &genreIds, const QString &type, int offset, bool append);
void requestPlaylistsPage(const QString &genreIds, const QString &type, const QString &tags, const QString &query, int offset, bool append);
bool chooseMultiGenres();
void updateMultiGenreLabel();
void setPlaylistItems(const QJsonArray &items, bool append = false);
}; };

View File

@@ -41,20 +41,34 @@ MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
const qint64 id = m_header->artistId(); const qint64 id = m_header->artistId();
if (id > 0) emit artistRequested(id); if (id > 0) emit artistRequested(id);
}); });
QObject::connect(m_header->followButton(), &QPushButton::clicked,
[this] {
const qint64 id = m_header->playlistId();
if (id <= 0 || m_header->playlistOwned())
return;
emit playlistFollowToggled(id, !m_header->playlistFollowed());
});
QObject::connect(m_header->favButton(), &QPushButton::clicked,
[this] {
const QString albumId = m_header->albumId();
if (albumId.isEmpty())
return;
emit albumFavoriteToggled(albumId, !m_header->albumFaved());
});
m_albumList = new AlbumListView(this); m_albumList = new AlbumListView(this);
m_artistList = new ArtistListView(this); m_artistList = new ArtistListView(this);
m_artistView = new ArtistView(backend, queue, this); m_artistView = new ArtistView(backend, queue, this);
m_genreBrowser = new GenreBrowserView(backend, this); m_genreBrowser = new GenreBrowserView(backend, queue, this);
m_stack->addWidget(m_welcome); // 0 m_stack->addWidget(m_welcome); // PageWelcome
m_stack->addWidget(tracksPage); // 1 m_stack->addWidget(tracksPage); // PageTracks
m_stack->addWidget(m_albumList); // 2 m_stack->addWidget(m_albumList); // PageAlbumList
m_stack->addWidget(m_artistList); // 3 m_stack->addWidget(m_artistList); // PageArtistList
m_stack->addWidget(m_artistView); // 4 m_stack->addWidget(m_artistView); // PageArtistDetail
m_stack->addWidget(m_genreBrowser); // 5 m_stack->addWidget(m_genreBrowser); // PageGenreBrowser
m_stack->setCurrentIndex(0); m_stack->setCurrentIndex(PageWelcome);
connect(m_albumList, &AlbumListView::albumSelected, this, &MainContent::albumRequested); connect(m_albumList, &AlbumListView::albumSelected, this, &MainContent::albumRequested);
connect(m_artistList, &ArtistListView::artistSelected, this, &MainContent::artistRequested); connect(m_artistList, &ArtistListView::artistSelected, this, &MainContent::artistRequested);
@@ -62,54 +76,59 @@ MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
connect(m_artistView, &ArtistView::playTrackRequested, this, &MainContent::playTrackRequested); connect(m_artistView, &ArtistView::playTrackRequested, this, &MainContent::playTrackRequested);
connect(m_genreBrowser, &GenreBrowserView::albumSelected, this, &MainContent::albumRequested); connect(m_genreBrowser, &GenreBrowserView::albumSelected, this, &MainContent::albumRequested);
connect(m_genreBrowser, &GenreBrowserView::artistSelected, this, &MainContent::artistRequested); connect(m_genreBrowser, &GenreBrowserView::artistSelected, this, &MainContent::artistRequested);
connect(m_genreBrowser, &GenreBrowserView::playlistSelected, this, &MainContent::playlistRequested);
connect(m_genreBrowser, &GenreBrowserView::playTrackRequested, this, &MainContent::playTrackRequested);
} }
void MainContent::showWelcome() { m_stack->setCurrentIndex(0); } void MainContent::showWelcome() { m_stack->setCurrentIndex(PageWelcome); }
void MainContent::showAlbum(const QJsonObject &album) void MainContent::showAlbum(const QJsonObject &album)
{ {
m_header->setAlbum(album); QString albumId = album["id"].toString();
if (albumId.isEmpty() && album["id"].isDouble())
albumId = QString::number(static_cast<qint64>(album["id"].toDouble()));
m_header->setAlbum(album, m_favAlbumIds.contains(albumId));
m_tracks->loadAlbum(album); m_tracks->loadAlbum(album);
m_stack->setCurrentIndex(1); m_stack->setCurrentIndex(PageTracks);
} }
void MainContent::showPlaylist(const QJsonObject &playlist) void MainContent::showPlaylist(const QJsonObject &playlist, bool isFollowed, bool isOwned)
{ {
m_header->setPlaylist(playlist); m_header->setPlaylist(playlist, isFollowed, isOwned);
m_tracks->loadPlaylist(playlist); m_tracks->loadPlaylist(playlist);
m_stack->setCurrentIndex(1); m_stack->setCurrentIndex(PageTracks);
} }
void MainContent::showFavTracks(const QJsonObject &result) void MainContent::showFavTracks(const QJsonObject &result)
{ {
m_header->hide(); m_header->hide();
m_tracks->loadTracks(result["items"].toArray()); m_tracks->loadTracks(result["items"].toArray());
m_stack->setCurrentIndex(1); m_stack->setCurrentIndex(PageTracks);
} }
void MainContent::showSearchTracks(const QJsonArray &tracks) void MainContent::showSearchTracks(const QJsonArray &tracks)
{ {
m_header->hide(); m_header->hide();
m_tracks->loadSearchTracks(tracks); m_tracks->loadSearchTracks(tracks);
m_stack->setCurrentIndex(1); m_stack->setCurrentIndex(PageTracks);
} }
void MainContent::showFavAlbums(const QJsonObject &result) void MainContent::showFavAlbums(const QJsonObject &result)
{ {
m_albumList->setAlbums(result["items"].toArray()); m_albumList->setAlbums(result["items"].toArray());
m_stack->setCurrentIndex(2); m_stack->setCurrentIndex(PageAlbumList);
} }
void MainContent::showFavArtists(const QJsonObject &result) void MainContent::showFavArtists(const QJsonObject &result)
{ {
m_artistList->setArtists(result["items"].toArray()); m_artistList->setArtists(result["items"].toArray());
m_stack->setCurrentIndex(3); m_stack->setCurrentIndex(PageArtistList);
} }
void MainContent::showArtist(const QJsonObject &artist) void MainContent::showArtist(const QJsonObject &artist)
{ {
m_artistView->setArtist(artist); m_artistView->setArtist(artist);
m_stack->setCurrentIndex(4); m_stack->setCurrentIndex(PageArtistDetail);
} }
void MainContent::updateArtistReleases(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset) void MainContent::updateArtistReleases(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset)
@@ -122,13 +141,36 @@ void MainContent::setFavArtistIds(const QSet<qint64> &ids)
m_artistView->setFavArtistIds(ids); m_artistView->setFavArtistIds(ids);
} }
void MainContent::setFavAlbumIds(const QSet<QString> &ids)
{
m_favAlbumIds = ids;
const QString shownAlbumId = m_header->albumId();
if (!shownAlbumId.isEmpty())
m_header->setAlbumFaved(m_favAlbumIds.contains(shownAlbumId));
}
void MainContent::onDeepShuffleTracks(const QJsonArray &tracks) void MainContent::onDeepShuffleTracks(const QJsonArray &tracks)
{ {
if (m_genreBrowser->tryHandleDeepShuffleTracks(tracks))
return;
m_artistView->onDeepShuffleTracks(tracks); m_artistView->onDeepShuffleTracks(tracks);
} }
void MainContent::showGenreBrowser() void MainContent::showGenreBrowser()
{ {
m_genreBrowser->ensureGenresLoaded(); m_genreBrowser->ensureGenresLoaded();
m_stack->setCurrentIndex(5); m_genreBrowser->setBrowseMode(GenreBrowserView::BrowseMode::Genres);
m_stack->setCurrentIndex(PageGenreBrowser);
}
void MainContent::showPlaylistBrowser()
{
m_genreBrowser->ensureGenresLoaded();
m_genreBrowser->setBrowseMode(GenreBrowserView::BrowseMode::PlaylistSearch);
m_stack->setCurrentIndex(PageGenreBrowser);
}
void MainContent::setCurrentPlaylistFollowed(bool followed)
{
m_header->setPlaylistFollowed(followed);
} }

View File

@@ -14,6 +14,7 @@
#include <QStackedWidget> #include <QStackedWidget>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonArray> #include <QJsonArray>
#include <QSet>
class MainContent : public QWidget class MainContent : public QWidget
{ {
@@ -26,25 +27,40 @@ public:
void showWelcome(); void showWelcome();
void showAlbum(const QJsonObject &album); void showAlbum(const QJsonObject &album);
void showPlaylist(const QJsonObject &playlist); void showPlaylist(const QJsonObject &playlist, bool isFollowed, bool isOwned);
void showFavTracks(const QJsonObject &result); void showFavTracks(const QJsonObject &result);
void showSearchTracks(const QJsonArray &tracks); void showSearchTracks(const QJsonArray &tracks);
void showFavAlbums(const QJsonObject &result); void showFavAlbums(const QJsonObject &result);
void showFavArtists(const QJsonObject &result); void showFavArtists(const QJsonObject &result);
void showArtist(const QJsonObject &artist); void showArtist(const QJsonObject &artist);
void updateArtistReleases(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset); void updateArtistReleases(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset);
void setFavAlbumIds(const QSet<QString> &ids);
void setFavArtistIds(const QSet<qint64> &ids); void setFavArtistIds(const QSet<qint64> &ids);
void onDeepShuffleTracks(const QJsonArray &tracks); void onDeepShuffleTracks(const QJsonArray &tracks);
void showGenreBrowser(); void showGenreBrowser();
void showPlaylistBrowser();
void setCurrentPlaylistFollowed(bool followed);
ArtistView *artistView() const { return m_artistView; } ArtistView *artistView() const { return m_artistView; }
signals: signals:
void albumRequested(const QString &albumId); void albumRequested(const QString &albumId);
void artistRequested(qint64 artistId); void artistRequested(qint64 artistId);
void albumFavoriteToggled(const QString &albumId, bool favorite);
void playlistRequested(qint64 playlistId);
void playlistFollowToggled(qint64 playlistId, bool follow);
void playTrackRequested(qint64 trackId); void playTrackRequested(qint64 trackId);
private: private:
enum StackPage {
PageWelcome = 0,
PageTracks = 1,
PageAlbumList = 2,
PageArtistList = 3,
PageArtistDetail = 4,
PageGenreBrowser = 5,
};
QobuzBackend *m_backend = nullptr; QobuzBackend *m_backend = nullptr;
QStackedWidget *m_stack = nullptr; QStackedWidget *m_stack = nullptr;
QLabel *m_welcome = nullptr; QLabel *m_welcome = nullptr;
@@ -54,4 +70,5 @@ private:
ArtistListView *m_artistList = nullptr; ArtistListView *m_artistList = nullptr;
ArtistView *m_artistView = nullptr; ArtistView *m_artistView = nullptr;
GenreBrowserView *m_genreBrowser = nullptr; GenreBrowserView *m_genreBrowser = nullptr;
QSet<QString> m_favAlbumIds;
}; };

View File

@@ -5,6 +5,8 @@
#include <QNetworkRequest> #include <QNetworkRequest>
#include <QResizeEvent> #include <QResizeEvent>
#include <QMenu> #include <QMenu>
#include <QDateTime>
#include <QSignalBlocker>
MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *parent) MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
: QToolBar(parent) : QToolBar(parent)
@@ -39,15 +41,62 @@ MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
connect(m_trackLabel, &QLabel::customContextMenuRequested, connect(m_trackLabel, &QLabel::customContextMenuRequested,
this, [this](const QPoint &pos) { this, [this](const QPoint &pos) {
if (m_currentTrack.isEmpty()) return; if (m_currentTrack.isEmpty()) return;
QMenu menu(this);
const qint64 trackId = static_cast<qint64>(m_currentTrack["id"].toDouble());
const QString albumId = m_currentTrack["album"].toObject()["id"].toString(); const QString albumId = m_currentTrack["album"].toObject()["id"].toString();
const QString albumTitle = m_currentTrack["album"].toObject()["title"].toString();
const qint64 artistId = static_cast<qint64>( const qint64 artistId = static_cast<qint64>(
m_currentTrack["performer"].toObject()["id"].toDouble()); m_currentTrack["performer"].toObject()["id"].toDouble());
if (!albumId.isEmpty()) const QString artistName = m_currentTrack["performer"].toObject()["name"].toString();
menu.addAction(tr("Go to Album"), this, [this, albumId] { emit albumRequested(albumId); });
if (artistId > 0) QMenu menu(this);
menu.addAction(tr("Go to Artist"), this, [this, artistId] { emit artistRequested(artistId); });
if (!menu.isEmpty()) 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"));
connect(addFav, &QAction::triggered, this, [this, trackId] {
emit favTrackRequested(trackId);
});
if (!albumId.isEmpty() || artistId > 0)
menu.addSeparator();
if (!albumId.isEmpty()) {
auto *openAlbum = menu.addAction(
QIcon(":/res/icons/view-media-album-cover.svg"),
tr("Open album: %1").arg(QString(albumTitle).replace(QLatin1Char('&'), QStringLiteral("&&"))));
connect(openAlbum, &QAction::triggered, this, [this, albumId] {
emit albumRequested(albumId);
});
}
if (artistId > 0) {
auto *openArtist = menu.addAction(
QIcon(":/res/icons/view-media-artist.svg"),
tr("Open artist: %1").arg(QString(artistName).replace(QLatin1Char('&'), QStringLiteral("&&"))));
connect(openArtist, &QAction::triggered, this, [this, artistId] {
emit artistRequested(artistId);
});
}
if (!m_userPlaylists.isEmpty()) {
menu.addSeparator();
auto *plMenu = menu.addMenu(QIcon(":/res/icons/media-playlist-append.svg"), tr("Add to playlist"));
for (const auto &pl : m_userPlaylists) {
auto *act = plMenu->addAction(QString(pl.second).replace(QLatin1Char('&'), QStringLiteral("&&")));
connect(act, &QAction::triggered, this, [this, trackId, plId = pl.first] {
emit addToPlaylistRequested(trackId, plId);
});
}
}
connect(playNext, &QAction::triggered, this, [this] {
m_queue->playNext(m_currentTrack);
});
connect(addQueue, &QAction::triggered, this, [this] {
m_queue->addToQueue(m_currentTrack);
});
menu.exec(m_trackLabel->mapToGlobal(pos)); menu.exec(m_trackLabel->mapToGlobal(pos));
}); });
@@ -94,6 +143,11 @@ MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
m_shuffle->setCheckable(true); m_shuffle->setCheckable(true);
connect(m_shuffle, &QAction::toggled, this, &MainToolBar::onShuffleToggled); connect(m_shuffle, &QAction::toggled, this, &MainToolBar::onShuffleToggled);
m_autoplay = addAction(Icon::autoplay(), tr("Autoplay"));
m_autoplay->setCheckable(true);
m_autoplay->setChecked(AppSettings::instance().autoplayEnabled());
connect(m_autoplay, &QAction::toggled, this, &MainToolBar::onAutoplayToggled);
// ---- Volume ---- // ---- Volume ----
m_volume = new VolumeButton(this); m_volume = new VolumeButton(this);
addWidget(m_volume); addWidget(m_volume);
@@ -118,6 +172,8 @@ MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
connect(m_backend, &QobuzBackend::positionChanged, this, &MainToolBar::onPositionChanged); connect(m_backend, &QobuzBackend::positionChanged, this, &MainToolBar::onPositionChanged);
connect(m_backend, &QobuzBackend::trackFinished, this, &MainToolBar::onTrackFinished); connect(m_backend, &QobuzBackend::trackFinished, this, &MainToolBar::onTrackFinished);
connect(m_backend, &QobuzBackend::trackTransitioned, this, &MainToolBar::onTrackTransitioned); connect(m_backend, &QobuzBackend::trackTransitioned, this, &MainToolBar::onTrackTransitioned);
connect(m_backend, &QobuzBackend::dynamicSuggestionsLoaded,
this, &MainToolBar::onDynamicSuggestionsLoaded);
// ---- Queue signals ---- // ---- Queue signals ----
connect(m_queue, &PlayQueue::queueChanged, this, &MainToolBar::onQueueChanged); connect(m_queue, &PlayQueue::queueChanged, this, &MainToolBar::onQueueChanged);
@@ -182,6 +238,18 @@ void MainToolBar::updateProgress(quint64 position, quint64 duration)
TrackListModel::formatDuration(static_cast<qint64>(duration)))); TrackListModel::formatDuration(static_cast<qint64>(duration))));
} }
void MainToolBar::setQueueToggleChecked(bool checked)
{
const QSignalBlocker blocker(m_queueBtn);
m_queueBtn->setChecked(checked);
}
void MainToolBar::setSearchToggleChecked(bool checked)
{
const QSignalBlocker blocker(m_search);
m_search->setChecked(checked);
}
// ---- private slots ---- // ---- private slots ----
void MainToolBar::onPlayPause() void MainToolBar::onPlayPause()
@@ -214,6 +282,10 @@ void MainToolBar::onProgressReleased()
const quint64 dur = m_backend->duration(); const quint64 dur = m_backend->duration();
if (dur > 0) { if (dur > 0) {
const quint64 target = dur * static_cast<quint64>(m_progress->value()) / 1000; const quint64 target = dur * static_cast<quint64>(m_progress->value()) / 1000;
m_seekPending = true;
m_pendingSeekTarget = target;
m_pendingSeekStartedMs = QDateTime::currentMSecsSinceEpoch();
updateProgress(target, dur);
m_backend->seek(target); m_backend->seek(target);
} }
} }
@@ -231,11 +303,40 @@ void MainToolBar::onBackendStateChanged(const QString &state)
void MainToolBar::onTrackChanged(const QJsonObject &track) void MainToolBar::onTrackChanged(const QJsonObject &track)
{ {
m_seekPending = false;
m_seeking = false;
setCurrentTrack(track); setCurrentTrack(track);
const qint64 trackId = static_cast<qint64>(track["id"].toDouble());
if (trackId <= 0)
return;
const qint64 artistId = static_cast<qint64>(
track["performer"].toObject()["id"].toDouble());
const qint64 genreId = static_cast<qint64>(
track["album"].toObject()["genre"].toObject()["id"].toDouble());
const qint64 labelId = static_cast<qint64>(
track["album"].toObject()["label"].toObject()["id"].toDouble());
m_recentTracks.append(RecentTrackSeed{trackId, artistId, genreId, labelId});
while (m_recentTracks.size() > 32)
m_recentTracks.removeFirst();
} }
void MainToolBar::onPositionChanged(quint64 position, quint64 duration) void MainToolBar::onPositionChanged(quint64 position, quint64 duration)
{ {
if (m_seekPending) {
const qint64 nowMs = QDateTime::currentMSecsSinceEpoch();
const quint64 delta = (position > m_pendingSeekTarget)
? (position - m_pendingSeekTarget)
: (m_pendingSeekTarget - position);
if (delta > 2 && (nowMs - m_pendingSeekStartedMs) < 1500)
return;
m_seekPending = false;
}
updateProgress(position, duration); updateProgress(position, duration);
} }
@@ -243,6 +344,8 @@ void MainToolBar::onTrackFinished()
{ {
if (m_queue->canGoNext()) { if (m_queue->canGoNext()) {
onNext(); onNext();
} else if (AppSettings::instance().autoplayEnabled()) {
requestAutoplaySuggestions();
} else { } else {
setPlaying(false); setPlaying(false);
m_progress->setValue(0); m_progress->setValue(0);
@@ -272,6 +375,71 @@ void MainToolBar::onShuffleToggled(bool checked)
m_queue->setShuffle(checked); m_queue->setShuffle(checked);
} }
void MainToolBar::onAutoplayToggled(bool checked)
{
AppSettings::instance().setAutoplayEnabled(checked);
}
void MainToolBar::requestAutoplaySuggestions()
{
if (m_fetchingAutoplay)
return;
QJsonArray listenedIds;
QJsonArray analyze;
const int n = m_recentTracks.size();
for (int i = 0; i < n; ++i) {
const RecentTrackSeed &t = m_recentTracks.at(i);
listenedIds.append(t.trackId);
if (i < qMax(0, n - 5))
continue;
analyze.append(QJsonObject{
{"track_id", t.trackId},
{"artist_id", t.artistId},
{"genre_id", t.genreId},
{"label_id", t.labelId},
});
}
if (listenedIds.isEmpty() || analyze.isEmpty()) {
setPlaying(false);
m_progress->setValue(0);
m_position->setText(QStringLiteral("0:00 / 0:00"));
return;
}
m_fetchingAutoplay = true;
m_backend->getDynamicSuggestions(listenedIds, analyze, 50);
}
void MainToolBar::onDynamicSuggestionsLoaded(const QJsonObject &result)
{
m_fetchingAutoplay = false;
QJsonArray items;
if (result["tracks"].isObject())
items = result["tracks"].toObject()["items"].toArray();
if (items.isEmpty() && result["dynamic"].isObject())
items = result["dynamic"].toObject()["items"].toArray();
if (items.isEmpty())
items = result["items"].toArray();
if (items.isEmpty()) {
setPlaying(false);
m_progress->setValue(0);
m_position->setText(QStringLiteral("0:00 / 0:00"));
return;
}
m_queue->appendToContext(items);
if (m_queue->canGoNext())
onNext();
}
void MainToolBar::fetchAlbumArt(const QString &url) void MainToolBar::fetchAlbumArt(const QString &url)
{ {
m_nam->get(QNetworkRequest(QUrl(url))); m_nam->get(QNetworkRequest(QUrl(url)));

View File

@@ -12,6 +12,8 @@
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QNetworkReply> #include <QNetworkReply>
#include <QJsonObject> #include <QJsonObject>
#include <QPair>
#include <QVector>
class MainToolBar : public QToolBar class MainToolBar : public QToolBar
{ {
@@ -23,12 +25,18 @@ public:
void setPlaying(bool playing); void setPlaying(bool playing);
void setCurrentTrack(const QJsonObject &track); void setCurrentTrack(const QJsonObject &track);
void updateProgress(quint64 position, quint64 duration); void updateProgress(quint64 position, quint64 duration);
void setQueueToggleChecked(bool checked);
void setSearchToggleChecked(bool checked);
void setUserPlaylists(const QVector<QPair<qint64, QString>> &playlists) { m_userPlaylists = playlists; }
signals: signals:
void searchToggled(bool visible); void searchToggled(bool visible);
void queueToggled(bool visible); void queueToggled(bool visible);
void albumRequested(const QString &albumId); void albumRequested(const QString &albumId);
void artistRequested(qint64 artistId); void artistRequested(qint64 artistId);
void addToPlaylistRequested(qint64 trackId, qint64 playlistId);
void favTrackRequested(qint64 trackId);
protected: protected:
void resizeEvent(QResizeEvent *event) override; void resizeEvent(QResizeEvent *event) override;
@@ -47,11 +55,20 @@ private slots:
void onTrackTransitioned(); void onTrackTransitioned();
void onQueueChanged(); void onQueueChanged();
void onShuffleToggled(bool checked); void onShuffleToggled(bool checked);
void onAutoplayToggled(bool checked);
void onDynamicSuggestionsLoaded(const QJsonObject &result);
void fetchAlbumArt(const QString &url); void fetchAlbumArt(const QString &url);
void onAlbumArtReady(QNetworkReply *reply); void onAlbumArtReady(QNetworkReply *reply);
private: private:
struct RecentTrackSeed {
qint64 trackId = 0;
qint64 artistId = 0;
qint64 genreId = 0;
qint64 labelId = 0;
};
QobuzBackend *m_backend = nullptr; QobuzBackend *m_backend = nullptr;
PlayQueue *m_queue = nullptr; PlayQueue *m_queue = nullptr;
@@ -65,6 +82,7 @@ private:
QLabel *m_position = nullptr; QLabel *m_position = nullptr;
QWidget *m_rightSpacer = nullptr; QWidget *m_rightSpacer = nullptr;
QAction *m_shuffle = nullptr; QAction *m_shuffle = nullptr;
QAction *m_autoplay = nullptr;
VolumeButton *m_volume = nullptr; VolumeButton *m_volume = nullptr;
QAction *m_queueBtn = nullptr; QAction *m_queueBtn = nullptr;
QAction *m_search = nullptr; QAction *m_search = nullptr;
@@ -72,6 +90,15 @@ private:
QNetworkAccessManager *m_nam = nullptr; QNetworkAccessManager *m_nam = nullptr;
QString m_currentArtUrl; QString m_currentArtUrl;
QJsonObject m_currentTrack; QJsonObject m_currentTrack;
QVector<RecentTrackSeed> m_recentTracks;
bool m_playing = false; bool m_playing = false;
bool m_seeking = false; bool m_seeking = false;
bool m_seekPending = false;
quint64 m_pendingSeekTarget = 0;
qint64 m_pendingSeekStartedMs = 0;
bool m_fetchingAutoplay = false;
QVector<QPair<qint64, QString>> m_userPlaylists;
void requestAutoplaySuggestions();
}; };

View File

@@ -10,9 +10,8 @@
static constexpr int UpcomingIndexRole = Qt::UserRole + 1; static constexpr int UpcomingIndexRole = Qt::UserRole + 1;
static constexpr int IsPlayNextRole = Qt::UserRole + 2; static constexpr int IsPlayNextRole = Qt::UserRole + 2;
static constexpr int TrackJsonRole = Qt::UserRole + 3; static constexpr int ArtistRole = Qt::UserRole + 3;
static constexpr int ArtistRole = Qt::UserRole + 4; static constexpr int DurationRole = Qt::UserRole + 4;
static constexpr int DurationRole = Qt::UserRole + 5;
// ---- Custom delegate ------------------------------------------------------- // ---- Custom delegate -------------------------------------------------------
@@ -184,7 +183,6 @@ void QueuePanel::refresh()
auto *item = new QListWidgetItem(title, m_list); auto *item = new QListWidgetItem(title, m_list);
item->setData(UpcomingIndexRole, i); item->setData(UpcomingIndexRole, i);
item->setData(IsPlayNextRole, i < playNextCount); item->setData(IsPlayNextRole, i < playNextCount);
item->setData(TrackJsonRole, QVariant::fromValue(t));
item->setData(ArtistRole, artist); item->setData(ArtistRole, artist);
item->setData(DurationRole, duration); item->setData(DurationRole, duration);
} }
@@ -205,10 +203,14 @@ void QueuePanel::onRowsMoved()
{ {
if (m_refreshing) return; if (m_refreshing) return;
const QVector<QJsonObject> currentUpcoming = m_queue->upcomingTracks();
QVector<QJsonObject> newOrder; QVector<QJsonObject> newOrder;
newOrder.reserve(m_list->count()); newOrder.reserve(m_list->count());
for (int i = 0; i < m_list->count(); ++i) for (int i = 0; i < m_list->count(); ++i) {
newOrder.append(m_list->item(i)->data(TrackJsonRole).value<QJsonObject>()); const int prevIndex = m_list->item(i)->data(UpcomingIndexRole).toInt();
if (prevIndex >= 0 && prevIndex < currentUpcoming.size())
newOrder.append(currentUpcoming.at(prevIndex));
}
m_refreshing = true; m_refreshing = true;
m_queue->setUpcomingOrder(newOrder); m_queue->setUpcomingOrder(newOrder);
@@ -223,8 +225,8 @@ void QueuePanel::onContextMenu(const QPoint &pos)
const int idx = item->data(UpcomingIndexRole).toInt(); const int idx = item->data(UpcomingIndexRole).toInt();
QMenu menu(this); QMenu menu(this);
auto *removeAct = menu.addAction(tr("Remove from queue")); auto *removeAct = menu.addAction(QIcon(":/res/icons/list-remove.svg"), tr("Remove from queue"));
auto *toTopAct = menu.addAction(tr("Move to top (play next)")); auto *toTopAct = menu.addAction(QIcon(":/res/icons/go-up.svg"), tr("Move to top (play next)"));
connect(removeAct, &QAction::triggered, this, [this, idx] { m_queue->removeUpcoming(idx); }); connect(removeAct, &QAction::triggered, this, [this, idx] { m_queue->removeUpcoming(idx); });
connect(toTopAct, &QAction::triggered, this, [this, idx] { m_queue->moveUpcomingToTop(idx); }); connect(toTopAct, &QAction::triggered, this, [this, idx] { m_queue->moveUpcomingToTop(idx); });

View File

@@ -1,4 +1,5 @@
#include "view.hpp" #include "view.hpp"
#include "../../util/colors.hpp"
#include "../../util/trackinfo.hpp" #include "../../util/trackinfo.hpp"
#include <QVBoxLayout> #include <QVBoxLayout>
@@ -39,6 +40,14 @@ SearchTab::SearchTab(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
// Result tabs // Result tabs
m_resultTabs = new QTabWidget(this); m_resultTabs = new QTabWidget(this);
m_topResults = new QTreeWidget(this);
m_topResults->setHeaderLabels({tr(""), tr("Top Result"), tr("Info")});
m_topResults->setRootIsDecorated(false);
m_topResults->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
m_topResults->header()->setSectionResizeMode(1, QHeaderView::Stretch);
m_topResults->header()->setSectionResizeMode(2, QHeaderView::Stretch);
m_topResults->header()->setStretchLastSection(false);
m_trackResults = new QTreeWidget(this); m_trackResults = new QTreeWidget(this);
m_trackResults->setHeaderLabels({tr("Title"), tr("Artist"), tr("Album")}); m_trackResults->setHeaderLabels({tr("Title"), tr("Artist"), tr("Album")});
m_trackResults->setRootIsDecorated(false); m_trackResults->setRootIsDecorated(false);
@@ -57,6 +66,7 @@ SearchTab::SearchTab(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
m_artistResults->setHeaderLabels({tr("Artist")}); m_artistResults->setHeaderLabels({tr("Artist")});
m_artistResults->setRootIsDecorated(false); m_artistResults->setRootIsDecorated(false);
m_resultTabs->addTab(m_topResults, tr("Top Results"));
m_resultTabs->addTab(m_trackResults, tr("Tracks")); m_resultTabs->addTab(m_trackResults, tr("Tracks"));
m_resultTabs->addTab(m_albumResults, tr("Albums")); m_resultTabs->addTab(m_albumResults, tr("Albums"));
m_resultTabs->addTab(m_artistResults, tr("Artists")); m_resultTabs->addTab(m_artistResults, tr("Artists"));
@@ -66,7 +76,9 @@ SearchTab::SearchTab(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
connect(m_searchBox, &QLineEdit::returnPressed, this, &SearchTab::onSearchSubmit); connect(m_searchBox, &QLineEdit::returnPressed, this, &SearchTab::onSearchSubmit);
connect(m_backend, &QobuzBackend::searchResult, this, &SearchTab::onSearchResult); connect(m_backend, &QobuzBackend::searchResult, this, &SearchTab::onSearchResult);
connect(m_backend, &QobuzBackend::mostPopularResult, this, &SearchTab::onMostPopularResult);
connect(m_topResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked);
connect(m_trackResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked); connect(m_trackResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked);
connect(m_albumResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked); connect(m_albumResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked);
connect(m_artistResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked); connect(m_artistResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked);
@@ -86,8 +98,67 @@ void SearchTab::setUserPlaylists(const QVector<QPair<qint64, QString>> &playlist
void SearchTab::onSearchSubmit() void SearchTab::onSearchSubmit()
{ {
const QString q = m_searchBox->text().trimmed(); const QString q = m_searchBox->text().trimmed();
if (!q.isEmpty()) if (!q.isEmpty()) {
m_backend->mostPopularSearch(q, 8);
m_backend->search(q, 0, 20); m_backend->search(q, 0, 20);
m_resultTabs->setCurrentIndex(0);
}
}
void SearchTab::onMostPopularResult(const QJsonObject &result)
{
m_topResults->clear();
QFont badgeFont;
badgeFont.setBold(true);
const QJsonArray items = result["most_popular"].toObject()["items"].toArray();
for (const auto &value : items) {
const QJsonObject itemObj = value.toObject();
const QString type = itemObj["type"].toString();
const QJsonObject content = itemObj["content"].toObject();
auto *item = new QTreeWidgetItem(m_topResults);
item->setData(0, JsonRole, content);
if (type == QStringLiteral("tracks")) {
const QString title = content["title"].toString();
const QString artist = content["performer"].toObject()["name"].toString();
const QString album = content["album"].toObject()["title"].toString();
item->setText(0, QStringLiteral("T"));
item->setForeground(0, Colors::BadgeGreen);
item->setFont(0, badgeFont);
item->setTextAlignment(0, Qt::AlignCenter);
item->setText(1, title);
item->setText(2, artist.isEmpty() ? album : QStringLiteral("%1 - %2").arg(artist, album));
item->setData(0, TypeRole, QStringLiteral("track"));
item->setData(0, IdRole, static_cast<qint64>(content["id"].toDouble()));
} else if (type == QStringLiteral("albums")) {
const QString title = content["title"].toString();
const QString artist = content["artist"].toObject()["name"].toString();
const bool hiRes = content["hires_streamable"].toBool()
|| content["rights"].toObject()["hires_streamable"].toBool();
item->setText(0, hiRes ? QStringLiteral("H") : QStringLiteral("A"));
item->setForeground(0, hiRes
? Colors::QobuzOrange
: Colors::BadgeGray);
item->setFont(0, badgeFont);
item->setTextAlignment(0, Qt::AlignCenter);
item->setText(1, title);
item->setText(2, artist);
item->setData(0, TypeRole, QStringLiteral("album"));
item->setData(1, IdRole, content["id"].toString());
} else if (type == QStringLiteral("artists")) {
item->setText(0, QStringLiteral("A"));
item->setForeground(0, Colors::BadgeBlue);
item->setFont(0, badgeFont);
item->setTextAlignment(0, Qt::AlignCenter);
item->setText(1, content["name"].toString());
item->setText(2, tr("Artist"));
item->setData(0, TypeRole, QStringLiteral("artist"));
item->setData(0, IdRole, static_cast<qint64>(content["id"].toDouble()));
}
}
} }
void SearchTab::onSearchResult(const QJsonObject &result) void SearchTab::onSearchResult(const QJsonObject &result)
@@ -123,7 +194,7 @@ void SearchTab::onSearchResult(const QJsonObject &result)
QStringList{QString(), a["title"].toString(), artist}); QStringList{QString(), a["title"].toString(), artist});
if (hiRes) { if (hiRes) {
item->setText(0, QStringLiteral("H")); item->setText(0, QStringLiteral("H"));
item->setForeground(0, QColor(QStringLiteral("#FFB232"))); item->setForeground(0, Colors::QobuzOrange);
item->setFont(0, hiResFont); item->setFont(0, hiResFont);
item->setTextAlignment(0, Qt::AlignCenter); item->setTextAlignment(0, Qt::AlignCenter);
} }
@@ -170,12 +241,15 @@ void SearchTab::onTrackContextMenu(const QPoint &pos)
QMenu menu(this); QMenu menu(this);
auto *playNow = menu.addAction(tr("Play now")); auto *playNow = menu.addAction(QIcon(":/res/icons/media-playback-start.svg"), tr("Play now"));
auto *playNext = menu.addAction(tr("Play next")); auto *playNext = menu.addAction(QIcon(":/res/icons/media-skip-forward.svg"), tr("Play next"));
auto *addQueue = menu.addAction(tr("Add to queue")); auto *addQueue = menu.addAction(QIcon(":/res/icons/media-playlist-append.svg"), tr("Add to queue"));
menu.addSeparator(); menu.addSeparator();
auto *addFav = menu.addAction(tr("Add to favorites")); auto *favAction = menu.addAction(QIcon(":/res/icons/starred-symbolic.svg"), tr("Add to favorites"));
connect(favAction, &QAction::triggered, this, [this, trackId] {
m_backend->addFavTrack(trackId);
});
// Open album / artist // Open album / artist
const QString albumId = trackJson["album"].toObject()["id"].toString(); const QString albumId = trackJson["album"].toObject()["id"].toString();
@@ -184,15 +258,20 @@ void SearchTab::onTrackContextMenu(const QPoint &pos)
const QString artistName = trackJson["performer"].toObject()["name"].toString(); const QString artistName = trackJson["performer"].toObject()["name"].toString();
const QString albumTitle = trackJson["album"].toObject()["title"].toString(); const QString albumTitle = trackJson["album"].toObject()["title"].toString();
if (!albumId.isEmpty() || artistId > 0)
menu.addSeparator(); menu.addSeparator();
if (!albumId.isEmpty()) { if (!albumId.isEmpty()) {
auto *openAlbum = menu.addAction(tr("Go to album: %1").arg(QString(albumTitle).replace(QLatin1Char('&'), QStringLiteral("&&")))); auto *openAlbum = menu.addAction(
QIcon(":/res/icons/view-media-album-cover.svg"),
tr("Open album: %1").arg(QString(albumTitle).replace(QLatin1Char('&'), QStringLiteral("&&"))));
connect(openAlbum, &QAction::triggered, this, [this, albumId] { connect(openAlbum, &QAction::triggered, this, [this, albumId] {
emit albumSelected(albumId); emit albumSelected(albumId);
}); });
} }
if (artistId > 0) { if (artistId > 0) {
auto *openArtist = menu.addAction(tr("Go to artist: %1").arg(QString(artistName).replace(QLatin1Char('&'), QStringLiteral("&&")))); auto *openArtist = menu.addAction(
QIcon(":/res/icons/view-media-artist.svg"),
tr("Open artist: %1").arg(QString(artistName).replace(QLatin1Char('&'), QStringLiteral("&&"))));
connect(openArtist, &QAction::triggered, this, [this, artistId] { connect(openArtist, &QAction::triggered, this, [this, artistId] {
emit artistSelected(artistId); emit artistSelected(artistId);
}); });
@@ -201,9 +280,9 @@ void SearchTab::onTrackContextMenu(const QPoint &pos)
// Add to playlist submenu // Add to playlist submenu
if (!m_userPlaylists.isEmpty()) { if (!m_userPlaylists.isEmpty()) {
menu.addSeparator(); menu.addSeparator();
auto *plMenu = menu.addMenu(tr("Add to playlist")); auto *plMenu = menu.addMenu(QIcon(":/res/icons/media-playlist-append.svg"), tr("Add to playlist"));
for (const auto &pl : m_userPlaylists) { for (const auto &pl : m_userPlaylists) {
auto *act = plMenu->addAction(pl.second); auto *act = plMenu->addAction(QString(pl.second).replace(QLatin1Char('&'), QStringLiteral("&&")));
connect(act, &QAction::triggered, this, [this, trackId, plId = pl.first] { connect(act, &QAction::triggered, this, [this, trackId, plId = pl.first] {
emit addToPlaylistRequested(trackId, plId); emit addToPlaylistRequested(trackId, plId);
}); });
@@ -223,9 +302,6 @@ void SearchTab::onTrackContextMenu(const QPoint &pos)
connect(addQueue, &QAction::triggered, this, [this, trackJson] { connect(addQueue, &QAction::triggered, this, [this, trackJson] {
m_queue->addToQueue(trackJson); m_queue->addToQueue(trackJson);
}); });
connect(addFav, &QAction::triggered, this, [this, trackId] {
m_backend->addFavTrack(trackId);
});
connect(info, &QAction::triggered, this, [this, trackJson] { connect(info, &QAction::triggered, this, [this, trackJson] {
showTrackInfo(trackJson); showTrackInfo(trackJson);
}); });
@@ -244,15 +320,17 @@ void SearchTab::onAlbumContextMenu(const QPoint &pos)
QMenu menu(this); QMenu menu(this);
auto *openAlbum = menu.addAction(tr("Open album")); auto *openAlbum = menu.addAction(QIcon(":/res/icons/view-media-album-cover.svg"), tr("Open album"));
auto *addFav = menu.addAction(tr("Add to favorites")); auto *addFav = menu.addAction(QIcon(":/res/icons/starred-symbolic.svg"), tr("Add to favorites"));
const qint64 artistId = static_cast<qint64>( const qint64 artistId = static_cast<qint64>(
albumJson["artist"].toObject()["id"].toDouble()); albumJson["artist"].toObject()["id"].toDouble());
const QString artistName = albumJson["artist"].toObject()["name"].toString(); const QString artistName = albumJson["artist"].toObject()["name"].toString();
if (artistId > 0) { if (artistId > 0) {
menu.addSeparator(); menu.addSeparator();
auto *openArtist = menu.addAction(tr("Go to artist: %1").arg(QString(artistName).replace(QLatin1Char('&'), QStringLiteral("&&")))); auto *openArtist = menu.addAction(
QIcon(":/res/icons/view-media-artist.svg"),
tr("Open artist: %1").arg(QString(artistName).replace(QLatin1Char('&'), QStringLiteral("&&"))));
connect(openArtist, &QAction::triggered, this, [this, artistId] { connect(openArtist, &QAction::triggered, this, [this, artistId] {
emit artistSelected(artistId); emit artistSelected(artistId);
}); });

View File

@@ -30,6 +30,7 @@ namespace SidePanel
private slots: private slots:
void onSearchResult(const QJsonObject &result); void onSearchResult(const QJsonObject &result);
void onMostPopularResult(const QJsonObject &result);
void onSearchSubmit(); void onSearchSubmit();
void onItemDoubleClicked(QTreeWidgetItem *item, int column); void onItemDoubleClicked(QTreeWidgetItem *item, int column);
@@ -38,6 +39,7 @@ namespace SidePanel
PlayQueue *m_queue = nullptr; PlayQueue *m_queue = nullptr;
QLineEdit *m_searchBox = nullptr; QLineEdit *m_searchBox = nullptr;
QTabWidget *m_resultTabs = nullptr; QTabWidget *m_resultTabs = nullptr;
QTreeWidget *m_topResults = nullptr;
QTreeWidget *m_trackResults = nullptr; QTreeWidget *m_trackResults = nullptr;
QTreeWidget *m_albumResults = nullptr; QTreeWidget *m_albumResults = nullptr;
QTreeWidget *m_artistResults = nullptr; QTreeWidget *m_artistResults = nullptr;

View File

@@ -1,5 +1,7 @@
#pragma once #pragma once
#include "../util/colors.hpp"
#include <QWidget> #include <QWidget>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QVBoxLayout> #include <QVBoxLayout>
@@ -63,7 +65,7 @@ public:
m_meta = new QLabel(info); m_meta = new QLabel(info);
QPalette mp = m_meta->palette(); QPalette mp = m_meta->palette();
mp.setColor(QPalette::WindowText, QColor(0xaa, 0xaa, 0xaa)); mp.setColor(QPalette::WindowText, Colors::SubduedText);
m_meta->setPalette(mp); m_meta->setPalette(mp);
vlay->addWidget(m_meta); vlay->addWidget(m_meta);
@@ -93,6 +95,20 @@ public:
"QPushButton:pressed { background: #333; }")); "QPushButton:pressed { background: #333; }"));
btnRow->addWidget(m_shuffleBtn); btnRow->addWidget(m_shuffleBtn);
m_favBtn = new QPushButton(tr("♡ Favourite"), info);
m_favBtn->setStyleSheet(btnBase +
QStringLiteral("QPushButton { background: #2a2a2a; color: #ccc; border: 1px solid #555; }"
"QPushButton:pressed { background: #333; }"));
m_favBtn->hide();
btnRow->addWidget(m_favBtn);
m_followBtn = new QPushButton(tr("Follow"), info);
m_followBtn->setStyleSheet(btnBase +
QStringLiteral("QPushButton { background: #2a2a2a; color: #ddd; border: 1px solid #666; }"
"QPushButton:pressed { background: #333; }"));
m_followBtn->hide();
btnRow->addWidget(m_followBtn);
btnRow->addStretch(); btnRow->addStretch();
vlay->addLayout(btnRow); vlay->addLayout(btnRow);
vlay->addStretch(1); vlay->addStretch(1);
@@ -113,28 +129,50 @@ public:
QPushButton *playButton() { return m_playBtn; } QPushButton *playButton() { return m_playBtn; }
QPushButton *shuffleButton() { return m_shuffleBtn; } QPushButton *shuffleButton() { return m_shuffleBtn; }
QPushButton *favButton() { return m_favBtn; }
QPushButton *followButton() { return m_followBtn; }
QPushButton *subtitleButton() { return m_subtitle; } QPushButton *subtitleButton() { return m_subtitle; }
QString albumId() const { return m_albumId; }
bool albumFaved() const { return m_albumFaved; }
qint64 artistId() const { return m_artistId; } qint64 artistId() const { return m_artistId; }
qint64 playlistId() const { return m_playlistId; }
bool playlistFollowed() const { return m_playlistFollowed; }
bool playlistOwned() const { return m_playlistOwned; }
void setAlbum(const QJsonObject &album) void setAlbum(const QJsonObject &album, bool isFaved)
{ {
const QString base = album["title"].toString(); const QString base = album["title"].toString();
const QString ver = album["version"].toString().trimmed(); const QString ver = album["version"].toString().trimmed();
m_title->setText(ver.isEmpty() ? base : base + QStringLiteral(" (") + ver + QLatin1Char(')')); m_title->setText(ver.isEmpty() ? base : base + QStringLiteral(" (") + ver + QLatin1Char(')'));
m_albumId = album["id"].toString();
if (m_albumId.isEmpty() && album["id"].isDouble())
m_albumId = QString::number(static_cast<qint64>(album["id"].toDouble()));
m_artistId = static_cast<qint64>(album["artist"].toObject()["id"].toDouble()); m_artistId = static_cast<qint64>(album["artist"].toObject()["id"].toDouble());
m_subtitle->setText(album["artist"].toObject()["name"].toString()); m_subtitle->setText(album["artist"].toObject()["name"].toString());
m_subtitle->setEnabled(m_artistId > 0); m_subtitle->setEnabled(m_artistId > 0);
m_subtitle->setCursor(m_artistId > 0 ? Qt::PointingHandCursor : Qt::ArrowCursor); m_subtitle->setCursor(m_artistId > 0 ? Qt::PointingHandCursor : Qt::ArrowCursor);
m_meta->setText(buildAlbumMeta(album)); m_meta->setText(buildAlbumMeta(album));
setAlbumFaved(isFaved);
m_favBtn->setEnabled(!m_albumId.isEmpty());
m_favBtn->show();
m_followBtn->hide();
m_playlistId = 0;
m_playlistFollowed = false;
m_playlistOwned = false;
fetchArt(album["image"].toObject()); fetchArt(album["image"].toObject());
show(); show();
} }
void setPlaylist(const QJsonObject &playlist) void setPlaylist(const QJsonObject &playlist, bool isFollowed, bool isOwned)
{ {
m_title->setText(playlist["name"].toString()); m_title->setText(playlist["name"].toString());
m_artistId = 0; m_artistId = 0;
m_playlistId = static_cast<qint64>(playlist["id"].toDouble());
m_playlistFollowed = isFollowed;
m_playlistOwned = isOwned;
const QString desc = playlist["description"].toString(); const QString desc = playlist["description"].toString();
const QString owner = playlist["owner"].toObject()["name"].toString(); const QString owner = playlist["owner"].toObject()["name"].toString();
m_subtitle->setText(desc.isEmpty() ? owner : desc); m_subtitle->setText(desc.isEmpty() ? owner : desc);
@@ -142,6 +180,20 @@ public:
m_subtitle->setCursor(Qt::ArrowCursor); m_subtitle->setCursor(Qt::ArrowCursor);
m_meta->setText(buildPlaylistMeta(playlist)); m_meta->setText(buildPlaylistMeta(playlist));
m_albumId.clear();
m_albumFaved = false;
m_favBtn->hide();
if (m_playlistOwned) {
m_followBtn->setText(tr("Owned"));
m_followBtn->setEnabled(false);
m_followBtn->show();
} else {
m_followBtn->setText(m_playlistFollowed ? tr("Unfollow") : tr("Follow"));
m_followBtn->setEnabled(m_playlistId > 0);
m_followBtn->show();
}
// Try images300 → images150 → images (API returns mosaic arrays, not image_rectangle) // Try images300 → images150 → images (API returns mosaic arrays, not image_rectangle)
const QJsonArray imgs300 = playlist["images300"].toArray(); const QJsonArray imgs300 = playlist["images300"].toArray();
const QJsonArray imgs150 = playlist["images150"].toArray(); const QJsonArray imgs150 = playlist["images150"].toArray();
@@ -156,6 +208,31 @@ public:
show(); show();
} }
void setPlaylistFollowed(bool followed)
{
m_playlistFollowed = followed;
if (!m_playlistOwned)
m_followBtn->setText(m_playlistFollowed ? tr("Unfollow") : tr("Follow"));
}
void setAlbumFaved(bool faved)
{
m_albumFaved = faved;
if (faved) {
m_favBtn->setText(tr("♥ Favourited"));
m_favBtn->setStyleSheet(QStringLiteral(
"QPushButton { padding: 5px 16px; border-radius: 4px; font-weight: bold;"
" background: #2a2a2a; color: #FFB232; border: 1px solid #FFB232; }"
"QPushButton:pressed { background: #333; }"));
} else {
m_favBtn->setText(tr("♡ Favourite"));
m_favBtn->setStyleSheet(QStringLiteral(
"QPushButton { padding: 5px 16px; border-radius: 4px; font-weight: bold;"
" background: #2a2a2a; color: #ccc; border: 1px solid #555; }"
"QPushButton:pressed { background: #333; }"));
}
}
private: private:
void fetchArt(const QJsonObject &img) void fetchArt(const QJsonObject &img)
{ {
@@ -221,7 +298,14 @@ private:
QLabel *m_meta = nullptr; QLabel *m_meta = nullptr;
QPushButton *m_playBtn = nullptr; QPushButton *m_playBtn = nullptr;
QPushButton *m_shuffleBtn = nullptr; QPushButton *m_shuffleBtn = nullptr;
QPushButton *m_favBtn = nullptr;
QPushButton *m_followBtn = nullptr;
QNetworkAccessManager *m_nam = nullptr; QNetworkAccessManager *m_nam = nullptr;
QString m_currentArtUrl; QString m_currentArtUrl;
QString m_albumId;
bool m_albumFaved = false;
qint64 m_artistId = 0; qint64 m_artistId = 0;
qint64 m_playlistId = 0;
bool m_playlistFollowed = false;
bool m_playlistOwned = false;
}; };