feat(search): integrate playlists into search page

- Add playlists to unified search results (SearchAllResults)
- Add dedicated Playlists tab in jump-nav
- Create SearchPlaylistCard with album-like overlay (heart/play/menu)
- Add playlist menu: Play next, Add to queue, Copy to library, Share
- Add offset parameter to search_playlists command
- Add PlaylistGenre and extra fields to Playlist model
- Update searchState with Playlist type and playlistResults

UI changes:
- Remove glyphs from search tabs
- Reduce tab spacing (gap 8px→4px, padding 16px→12px)
- Reduce filter font size (13px→12px)
This commit is contained in:
vicrodh committed 2026-02-02 07:57:16 -06:00
1 parent f6d2a6bddd
commit 61dd071326
7 files changed
+531 -15

No files matched your search

+6 -2
View File
@@ -723,13 +723,17 @@ impl QobuzClient {
}
/// Search playlists
pub async fn search_playlists(&self, query: &str, limit: u32) -> Result<SearchResultsPage<Playlist>> {
pub async fn search_playlists(&self, query: &str, limit: u32, offset: u32) -> Result<SearchResultsPage<Playlist>> {
let url = endpoints::build_url(paths::PLAYLIST_SEARCH);
let response: Value = self
.http
.get(&url)
.header("X-App-Id", self.app_id().await?)
.query(&[("query", query), ("limit", &limit.to_string())])
.query(&[
("query", query),
("limit", &limit.to_string()),
("offset", &offset.to_string()),
])
.send()
.await?
.json()
+13
View File
@@ -226,6 +226,12 @@ pub struct Playlist {
pub is_public: bool,
#[serde(default)]
pub tracks: Option<TracksContainer>,
// Additional fields from search results
pub genres: Option<Vec<PlaylistGenre>>,
pub images150: Option<Vec<String>>,
pub images300: Option<Vec<String>>,
pub slug: Option<String>,
pub users_count: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -236,6 +242,13 @@ pub struct PlaylistOwner {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaylistGenre {
pub id: u64,
pub name: String,
pub slug: Option<String>,
}
/// Image set with multiple resolutions
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ImageSet {
+3 -2
View File
@@ -48,13 +48,14 @@ pub async fn get_playlist(
pub async fn search_playlists(
query: String,
limit: Option<u32>,
offset: Option<u32>,
state: State<'_, AppState>,
) -> Result<SearchResultsPage<Playlist>, String> {
log::info!("Command: search_playlists \"{}\"", query);
log::info!("Command: search_playlists \"{}\" limit={:?} offset={:?}", query, limit, offset);
let client = state.client.lock().await;
client
.search_playlists(&query, limit.unwrap_or(20))
.search_playlists(&query, limit.unwrap_or(20), offset.unwrap_or(0))
.await
.map_err(|e| format!("Failed to search playlists: {}", e))
}
+9 -1
View File
@@ -2,7 +2,7 @@
use tauri::State;
use crate::api::{endpoints, endpoints::paths, Album, Artist, ArtistAlbums, LabelDetail, SearchResultsPage, Track, TracksContainer};
use crate::api::{endpoints, endpoints::paths, Album, Artist, ArtistAlbums, LabelDetail, Playlist, SearchResultsPage, Track, TracksContainer};
use crate::api_cache::ApiCacheState;
use crate::AppState;
use serde::{Deserialize, Serialize};
@@ -22,6 +22,7 @@ pub struct SearchAllResults {
pub albums: SearchResultsPage<Album>,
pub tracks: SearchResultsPage<Track>,
pub artists: SearchResultsPage<Artist>,
pub playlists: SearchResultsPage<Playlist>,
pub most_popular: Option<MostPopularItem>,
}
@@ -116,6 +117,12 @@ pub async fn search_all(
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_else(|| SearchResultsPage { items: vec![], total: 0, offset: 0, limit: 30 });
// Parse playlists
let playlists: SearchResultsPage<Playlist> = response
.get("playlists")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_else(|| SearchResultsPage { items: vec![], total: 0, offset: 0, limit: 30 });
// Parse most_popular - get the first item
let most_popular: Option<MostPopularItem> = response
.get("most_popular")
@@ -149,6 +156,7 @@ pub async fn search_all(
albums,
tracks,
artists,
playlists,
most_popular,
})
}