From 585053bd422f092ccb4c923c637624beb77e2bf0 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Fri, 20 Feb 2026 02:51:47 -0600 Subject: [PATCH] feat(label): redesign label detail view with Android-style sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the flat album grid LabelView with a rich detail page featuring: - Header with label image, name, description, and play button - Popular Tracks section (5/10/all expand, same UX as ArtistDetailView) - Releases horizontal scroll row with "See all" → LabelReleasesView - Critics' Picks section from second releases container - Playlists, Artists, and More Labels sections Backend: add /label/page and /label/explore API endpoints with V2 commands (v2_get_label_page, v2_get_label_explore). Models use serde_json::Value for flexible section parsing. Old LabelView renamed to LabelReleasesView for the "See all" flow. i18n keys added to all 4 locales (en, es, de, fr). --- crates/qbz-core/src/core.rs | 29 +- crates/qbz-models/src/lib.rs | 3 +- crates/qbz-models/src/types.rs | 41 + crates/qbz-qobuz/src/client.rs | 43 + crates/qbz-qobuz/src/endpoints.rs | 2 + src-tauri/src/api/client.rs | 43 + src-tauri/src/api/endpoints.rs | 2 + src-tauri/src/api/models.rs | 41 + src-tauri/src/commands_v2.rs | 46 +- src-tauri/src/core_bridge.rs | 23 +- src-tauri/src/lib.rs | 2 + .../components/views/LabelReleasesView.svelte | 623 ++++++ src/lib/components/views/LabelView.svelte | 1728 ++++++++++++----- src/lib/i18n/locales/de.json | 14 + src/lib/i18n/locales/en.json | 14 + src/lib/i18n/locales/es.json | 14 + src/lib/i18n/locales/fr.json | 14 + src/lib/stores/navigationStore.ts | 1 + src/lib/stores/playbackContextStore.ts | 11 +- src/lib/types/index.ts | 29 + src/routes/+page.svelte | 35 + 21 files changed, 2297 insertions(+), 461 deletions(-) create mode 100644 src/lib/components/views/LabelReleasesView.svelte diff --git a/crates/qbz-core/src/core.rs b/crates/qbz-core/src/core.rs index 4e59726df..c38e96cef 100644 --- a/crates/qbz-core/src/core.rs +++ b/crates/qbz-core/src/core.rs @@ -8,7 +8,8 @@ use tokio::sync::RwLock; use qbz_models::{ Album, Artist, CoreEvent, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, - DiscoverResponse, FrontendAdapter, GenreInfo, LabelDetail, PageArtistResponse, Playlist, + DiscoverResponse, FrontendAdapter, GenreInfo, LabelDetail, LabelExploreResponse, + LabelPageData, PageArtistResponse, Playlist, PlaylistTag, Quality, QueueState, QueueTrack, RepeatMode, SearchResultsPage, StreamUrl, Track, UserSession, }; use qbz_player::{Player, PlaybackState, QueueManager}; @@ -734,6 +735,32 @@ impl QbzCore { .map_err(CoreError::Api) } + /// Get label page (aggregated: top tracks, releases, playlists, artists) + pub async fn get_label_page(&self, label_id: u64) -> Result { + let client = self.client.read().await; + let client = client.as_ref().ok_or(CoreError::NotInitialized)?; + + client + .get_label_page(label_id) + .await + .map_err(CoreError::Api) + } + + /// Get label explore (discover more labels) + pub async fn get_label_explore( + &self, + limit: u32, + offset: u32, + ) -> Result { + let client = self.client.read().await; + let client = client.as_ref().ok_or(CoreError::NotInitialized)?; + + client + .get_label_explore(limit, offset) + .await + .map_err(CoreError::Api) + } + // ==================== Event Emission ==================== /// Emit an event to the frontend adapter diff --git a/crates/qbz-models/src/lib.rs b/crates/qbz-models/src/lib.rs index 09d555fc6..2367911da 100644 --- a/crates/qbz-models/src/lib.rs +++ b/crates/qbz-models/src/lib.rs @@ -42,7 +42,8 @@ pub use playback::{PlaybackState, PlaybackStatus, QueueState, QueueTrack, Repeat pub use traits::{FrontendAdapter, LoggingAdapter, NoOpAdapter}; pub use types::{ Album, AlbumSummary, Artist, ArtistAlbums, ArtistBiography, Favorites, Genre, GenreInfo, - GenreListContainer, GenreListResponse, ImageSet, Label, LabelDetail, Playlist, + GenreListContainer, GenreListResponse, ImageSet, Label, LabelDetail, + LabelExploreResponse, LabelPageContainer, LabelPageData, LabelPageGenericList, Playlist, PlaylistDuplicateResult, PlaylistGenre, PlaylistOwner, PlaylistWithTrackIds, Quality, SearchResults, SearchResultsPage, StreamRestriction, StreamUrl, Track, TracksContainer, UserSession, diff --git a/crates/qbz-models/src/types.rs b/crates/qbz-models/src/types.rs index 8ada888ea..78defe80c 100644 --- a/crates/qbz-models/src/types.rs +++ b/crates/qbz-models/src/types.rs @@ -353,6 +353,47 @@ pub struct LabelDetail { pub albums_count: Option, } +// ============ Label Page Types (/label/page) ============ + +/// Top-level response from /label/page +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LabelPageData { + pub id: u64, + pub name: String, + pub description: Option, + #[serde(default)] + pub image: Option, + #[serde(default)] + pub releases: Option>, + #[serde(default)] + pub playlists: Option, + #[serde(default)] + pub top_tracks: Option>, + #[serde(default)] + pub top_artists: Option, +} + +/// A container within label page (e.g. releases category) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LabelPageContainer { + pub id: Option, + pub data: Option, +} + +/// Generic list with has_more and items +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LabelPageGenericList { + pub has_more: Option, + pub items: Option>, +} + +/// Response from /label/explore +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LabelExploreResponse { + pub has_more: Option, + pub items: Option>, +} + /// Genre model #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Genre { diff --git a/crates/qbz-qobuz/src/client.rs b/crates/qbz-qobuz/src/client.rs index 7162bb821..1f335a549 100644 --- a/crates/qbz-qobuz/src/client.rs +++ b/crates/qbz-qobuz/src/client.rs @@ -1012,6 +1012,49 @@ impl QobuzClient { Ok(playlist) } + /// Get label page (aggregated: top tracks, releases, playlists, artists) + pub async fn get_label_page(&self, label_id: u64) -> Result { + let url = endpoints::build_url(paths::LABEL_PAGE); + + log::debug!("[API] get_label_page({})", label_id); + let response: serde_json::Value = self + .http + .get(&url) + .headers(self.api_headers().await?) + .query(&[("label_id", label_id.to_string())]) + .send() + .await? + .json() + .await?; + + Ok(serde_json::from_value(response)?) + } + + /// Get label explore (discover more labels) + pub async fn get_label_explore( + &self, + limit: u32, + offset: u32, + ) -> Result { + let url = endpoints::build_url(paths::LABEL_EXPLORE); + + log::debug!("[API] get_label_explore(limit={}, offset={})", limit, offset); + let response: serde_json::Value = self + .http + .get(&url) + .headers(self.api_headers().await?) + .query(&[ + ("limit", limit.to_string()), + ("offset", offset.to_string()), + ]) + .send() + .await? + .json() + .await?; + + Ok(serde_json::from_value(response)?) + } + /// Get label by ID with albums pub async fn get_label(&self, label_id: u64, limit: u32, offset: u32) -> Result { let url = endpoints::build_url(paths::LABEL_GET); diff --git a/crates/qbz-qobuz/src/endpoints.rs b/crates/qbz-qobuz/src/endpoints.rs index 796b41a48..7f1a0d823 100644 --- a/crates/qbz-qobuz/src/endpoints.rs +++ b/crates/qbz-qobuz/src/endpoints.rs @@ -44,6 +44,8 @@ pub mod paths { // Label pub const LABEL_GET: &str = "/label/get"; + pub const LABEL_PAGE: &str = "/label/page"; + pub const LABEL_EXPLORE: &str = "/label/explore"; // Genre pub const GENRE_LIST: &str = "/genre/list"; diff --git a/src-tauri/src/api/client.rs b/src-tauri/src/api/client.rs index dc97e6d6e..83d6fee23 100644 --- a/src-tauri/src/api/client.rs +++ b/src-tauri/src/api/client.rs @@ -1168,6 +1168,49 @@ impl QobuzClient { Ok(playlist) } + /// Get label page (aggregated: top tracks, releases, playlists, artists) + pub async fn get_label_page(&self, label_id: u64) -> Result { + let url = endpoints::build_url(paths::LABEL_PAGE); + + log::debug!("[API] get_label_page({})", label_id); + let response: serde_json::Value = self + .http + .get(&url) + .headers(self.api_headers().await?) + .query(&[("label_id", label_id.to_string())]) + .send() + .await? + .json() + .await?; + + Ok(serde_json::from_value(response)?) + } + + /// Get label explore (discover more labels) + pub async fn get_label_explore( + &self, + limit: u32, + offset: u32, + ) -> Result { + let url = endpoints::build_url(paths::LABEL_EXPLORE); + + log::debug!("[API] get_label_explore(limit={}, offset={})", limit, offset); + let response: serde_json::Value = self + .http + .get(&url) + .headers(self.api_headers().await?) + .query(&[ + ("limit", limit.to_string()), + ("offset", offset.to_string()), + ]) + .send() + .await? + .json() + .await?; + + Ok(serde_json::from_value(response)?) + } + /// Get label by ID with albums pub async fn get_label(&self, label_id: u64, limit: u32, offset: u32) -> Result { let url = endpoints::build_url(paths::LABEL_GET); diff --git a/src-tauri/src/api/endpoints.rs b/src-tauri/src/api/endpoints.rs index e7e984451..35d34402f 100644 --- a/src-tauri/src/api/endpoints.rs +++ b/src-tauri/src/api/endpoints.rs @@ -47,6 +47,8 @@ pub mod paths { // Label pub const LABEL_GET: &str = "/label/get"; + pub const LABEL_PAGE: &str = "/label/page"; + pub const LABEL_EXPLORE: &str = "/label/explore"; // Genre pub const GENRE_LIST: &str = "/genre/list"; diff --git a/src-tauri/src/api/models.rs b/src-tauri/src/api/models.rs index b1297eabd..ac0d170b2 100644 --- a/src-tauri/src/api/models.rs +++ b/src-tauri/src/api/models.rs @@ -445,6 +445,47 @@ pub struct LabelDetail { pub albums_count: Option, } +// ============ Label Page Types (/label/page) ============ + +/// Top-level response from /label/page +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LabelPageData { + pub id: u64, + pub name: String, + pub description: Option, + #[serde(default)] + pub image: Option, + #[serde(default)] + pub releases: Option>, + #[serde(default)] + pub playlists: Option, + #[serde(default)] + pub top_tracks: Option>, + #[serde(default)] + pub top_artists: Option, +} + +/// A container within label page (e.g. releases category) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LabelPageContainer { + pub id: Option, + pub data: Option, +} + +/// Generic list with has_more and items +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LabelPageGenericList { + pub has_more: Option, + pub items: Option>, +} + +/// Response from /label/explore +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LabelExploreResponse { + pub has_more: Option, + pub items: Option>, +} + /// Genre model (basic, used in album responses) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Genre { diff --git a/src-tauri/src/commands_v2.rs b/src-tauri/src/commands_v2.rs index 15f27b26c..79a54ecf4 100644 --- a/src-tauri/src/commands_v2.rs +++ b/src-tauri/src/commands_v2.rs @@ -11,7 +11,8 @@ use tokio::sync::RwLock; use qbz_models::{ Album, Artist, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, DiscoverResponse, - GenreInfo, LabelDetail, PageArtistResponse, Playlist, PlaylistTag, Quality, QueueState, + GenreInfo, LabelDetail, LabelExploreResponse, LabelPageData, PageArtistResponse, Playlist, + PlaylistTag, Quality, QueueState, QueueTrack as CoreQueueTrack, RepeatMode, SearchResultsPage, Track, UserSession, }; @@ -6454,6 +6455,49 @@ pub async fn v2_get_label( .map_err(RuntimeError::Internal) } +/// Get label page (aggregated: top tracks, releases, playlists, artists) +#[tauri::command] +#[allow(non_snake_case)] +pub async fn v2_get_label_page( + labelId: u64, + bridge: State<'_, CoreBridgeState>, + runtime: State<'_, RuntimeManagerState>, +) -> Result { + runtime + .manager() + .check_requirements(CommandRequirement::RequiresCoreBridgeAuth) + .await?; + + log::info!("[V2] get_label_page: {}", labelId); + let bridge = bridge.get().await; + bridge + .get_label_page(labelId) + .await + .map_err(RuntimeError::Internal) +} + +/// Get label explore (discover more labels) +#[tauri::command] +#[allow(non_snake_case)] +pub async fn v2_get_label_explore( + limit: u32, + offset: u32, + bridge: State<'_, CoreBridgeState>, + runtime: State<'_, RuntimeManagerState>, +) -> Result { + runtime + .manager() + .check_requirements(CommandRequirement::RequiresCoreBridgeAuth) + .await?; + + log::info!("[V2] get_label_explore: limit={} offset={}", limit, offset); + let bridge = bridge.get().await; + bridge + .get_label_explore(limit, offset) + .await + .map_err(RuntimeError::Internal) +} + // ==================== Integrations V2 Commands ==================== // // These commands use the qbz-integrations crate which is Tauri-independent. diff --git a/src-tauri/src/core_bridge.rs b/src-tauri/src/core_bridge.rs index 5237a160d..e329327a4 100644 --- a/src-tauri/src/core_bridge.rs +++ b/src-tauri/src/core_bridge.rs @@ -14,7 +14,8 @@ use qbz_audio::{settings::AudioSettingsStore, AudioDiagnostic, AudioSettings, Vi use qbz_core::QbzCore; use qbz_models::{ Album, Artist, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, DiscoverResponse, - GenreInfo, LabelDetail, PageArtistResponse, Playlist, PlaylistTag, Quality, QueueState, + GenreInfo, LabelDetail, LabelExploreResponse, LabelPageData, PageArtistResponse, Playlist, + PlaylistTag, Quality, QueueState, QueueTrack, RepeatMode, SearchResultsPage, StreamUrl, Track, UserSession, }; use qbz_player::{PlaybackState, Player}; @@ -499,6 +500,26 @@ impl CoreBridge { .map_err(|e| e.to_string()) } + /// Get label page (aggregated: top tracks, releases, playlists, artists) + pub async fn get_label_page(&self, label_id: u64) -> Result { + self.core + .get_label_page(label_id) + .await + .map_err(|e| e.to_string()) + } + + /// Get label explore (discover more labels) + pub async fn get_label_explore( + &self, + limit: u32, + offset: u32, + ) -> Result { + self.core + .get_label_explore(limit, offset) + .await + .map_err(|e| e.to_string()) + } + // ==================== Streaming ==================== /// Get stream URL for a track with quality fallback diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 144ef64d5..3cfd389d4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -981,6 +981,8 @@ pub fn run() { commands_v2::v2_get_similar_artists, commands_v2::v2_get_artist_with_albums, commands_v2::v2_get_label, + commands_v2::v2_get_label_page, + commands_v2::v2_get_label_explore, commands_v2::v2_pause_playback, commands_v2::v2_resume_playback, commands_v2::v2_stop_playback, diff --git a/src/lib/components/views/LabelReleasesView.svelte b/src/lib/components/views/LabelReleasesView.svelte new file mode 100644 index 000000000..3c49101de --- /dev/null +++ b/src/lib/components/views/LabelReleasesView.svelte @@ -0,0 +1,623 @@ + + +
+ +
+ +
+ +
+
+

{label?.name || labelName || 'Label'}

+

+ {#if loading} + Loading... + {:else if totalAlbums > 0} + {totalAlbums} album{totalAlbums !== 1 ? 's' : ''} + {/if} +

+
+
+ + + + + +
+ {#if loading} +
+
+

Loading label...

+
+ {:else if error} +
+

Failed to load label

+

{error}

+ +
+ {:else if albums.length === 0} +
+ +

No albums found for this label

+
+ {:else if apiSearching} +
+
+

Searching...

+
+ {:else if displayAlbums.length === 0} +
+ +

No albums match "{searchQuery}"

+
+ {:else} +
+ {#each displayAlbums as album (album.id)} + onAlbumClick?.(album.id)} + onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined} + onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined} + onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined} + onAddAlbumToPlaylist={onAddAlbumToPlaylist ? () => onAddAlbumToPlaylist(album.id) : undefined} + onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined} + onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined} + onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined} + isAlbumFullyDownloaded={isAlbumDownloaded(album.id)} + onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined} + onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined} + {downloadStateVersion} + /> + {/each} +
+ + {#if loadingMore} +
+ + Loading more albums... +
+ {/if} + {/if} +
+
+ + diff --git a/src/lib/components/views/LabelView.svelte b/src/lib/components/views/LabelView.svelte index 3c49101de..2cf0e31e0 100644 --- a/src/lib/components/views/LabelView.svelte +++ b/src/lib/components/views/LabelView.svelte @@ -1,10 +1,32 @@ -
- -
- -
- +
+ {#if loading} +
+
+

{$t('actions.loading')}

-
-

{label?.name || labelName || 'Label'}

-

- {#if loading} - Loading... - {:else if totalAlbums > 0} - {totalAlbums} album{totalAlbums !== 1 ? 's' : ''} + {:else if error} +

+ +

{error}

+ +
+ {:else if pageData} + +
+
+ {#if getLabelImage(pageData)} + {pageData.name} + {:else} +
+ +
{/if} -

-
-
- - - + {/if} + {#if topTracks.length > 0} + + {/if} +
+
- -
- {#if loading} -
-
-

Loading label...

-
- {:else if error} -
-

Failed to load label

-

{error}

- -
- {:else if albums.length === 0} -
- -

No albums found for this label

-
- {:else if apiSearching} -
-
-

Searching...

-
- {:else if displayAlbums.length === 0} -
- -

No albums match "{searchQuery}"

-
- {:else} -
- {#each displayAlbums as album (album.id)} - onAlbumClick?.(album.id)} - onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined} - onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined} - onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined} - onAddAlbumToPlaylist={onAddAlbumToPlaylist ? () => onAddAlbumToPlaylist(album.id) : undefined} - onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined} - onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined} - onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined} - isAlbumFullyDownloaded={isAlbumDownloaded(album.id)} - onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined} - onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined} - {downloadStateVersion} - /> - {/each} -
- - {#if loadingMore} -
- - Loading more albums... + + {#if topTracks.length > 0} + {/if} -
+ + + {#if releases.length > 0} +
+ + {#snippet header()} +

{$t('label.releases')}

+ {#if onNavigateReleases} + + {/if} + {/snippet} + {#snippet children()} + {#each releases.slice(0, 20) as album (album.id)} + onAlbumClick?.(album.id)} + onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined} + onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined} + onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined} + onAddAlbumToPlaylist={onAddAlbumToPlaylist ? () => onAddAlbumToPlaylist(album.id) : undefined} + onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined} + onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined} + onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined} + isAlbumFullyDownloaded={isAlbumDownloaded(album.id)} + onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined} + onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined} + {downloadStateVersion} + /> + {/each} +
+ {/snippet} +
+
+ {/if} + + + {#if criticsPicks.length > 0} +
+ + {#snippet children()} + {#each criticsPicks.slice(0, 20) as album (album.id)} + onAlbumClick?.(album.id)} + onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined} + onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined} + onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined} + onAddAlbumToPlaylist={onAddAlbumToPlaylist ? () => onAddAlbumToPlaylist(album.id) : undefined} + onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined} + onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined} + {downloadStateVersion} + /> + {/each} +
+ {/snippet} +
+
+ {/if} + + + {#if playlists.length > 0} +
+ + {#snippet children()} + {#each playlists as playlist} + {@const pid = (playlist.id as number) || 0} + {@const pname = String(playlist.name || playlist.title || '')} + {@const powner = (playlist.owner as Record)?.name as string || ''} + onPlaylistClick?.(pid)} + /> + {/each} +
+ {/snippet} +
+
+ {/if} + + + {#if artists.length > 0} +
+ + {#snippet children()} + {#each artists as artist} + {@const artistId = artist.id as number} + {@const artistName = getArtistName(artist)} + {@const artistImage = getArtistImage(artist)} + + {/each} +
+ {/snippet} +
+
+ {/if} + + + {#if moreLabels.length > 0} +
+ + {#snippet children()} + {#each moreLabels as item} + {@const itemImage = parseLabelExploreImage(item)} + + {/each} +
+ {/snippet} +
+
+ {/if} + {/if}
diff --git a/src/lib/i18n/locales/de.json b/src/lib/i18n/locales/de.json index 301be8222..d3729a47d 100644 --- a/src/lib/i18n/locales/de.json +++ b/src/lib/i18n/locales/de.json @@ -81,6 +81,20 @@ "noPlaylistsFor": "Keine Playlists gefunden für \"{query}\"", "viewMore": "Mehr anzeigen" }, + "label": { + "title": "Label", + "popularTracks": "Beliebte Titel", + "releases": "Veröffentlichungen", + "criticsPicks": "Kritiker-Auswahl", + "playlists": "Playlists", + "artists": "Künstler", + "moreLabels": "Weitere Labels", + "readMore": "MEHR LESEN", + "readLess": "WENIGER", + "seeAll": "Alle anzeigen", + "showMore": "Mehr anzeigen", + "showLess": "Weniger anzeigen" + }, "library": { "title": "Lokale Bibliothek", "browse": "Bibliothek durchsuchen", diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index 70f85f2a9..71c5a82da 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -84,6 +84,20 @@ "noPlaylistsFor": "No playlists found for \"{query}\"", "viewMore": "View more" }, + "label": { + "title": "Label", + "popularTracks": "Popular Tracks", + "releases": "Releases", + "criticsPicks": "Critics' Picks", + "playlists": "Playlists", + "artists": "Artists", + "moreLabels": "More labels", + "readMore": "READ MORE", + "readLess": "READ LESS", + "seeAll": "See all", + "showMore": "Show more", + "showLess": "Show less" + }, "library": { "title": "Local Library", "browse": "Browse Library", diff --git a/src/lib/i18n/locales/es.json b/src/lib/i18n/locales/es.json index a94fc6a2d..832c749be 100644 --- a/src/lib/i18n/locales/es.json +++ b/src/lib/i18n/locales/es.json @@ -84,6 +84,20 @@ "noPlaylistsFor": "No se encontraron listas para \"{query}\"", "viewMore": "Ver más" }, + "label": { + "title": "Sello", + "popularTracks": "Canciones populares", + "releases": "Lanzamientos", + "criticsPicks": "Selección de la crítica", + "playlists": "Playlists", + "artists": "Artistas", + "moreLabels": "Más sellos", + "readMore": "LEER MÁS", + "readLess": "LEER MENOS", + "seeAll": "Ver todo", + "showMore": "Ver más", + "showLess": "Ver menos" + }, "library": { "title": "Biblioteca Local", "browse": "Explorar Biblioteca", diff --git a/src/lib/i18n/locales/fr.json b/src/lib/i18n/locales/fr.json index 8e1899c07..9e8524b99 100644 --- a/src/lib/i18n/locales/fr.json +++ b/src/lib/i18n/locales/fr.json @@ -81,6 +81,20 @@ "noPlaylistsFor": "Aucune playlist trouvée pour « {query} »", "viewMore": "Voir plus" }, + "label": { + "title": "Label", + "popularTracks": "Titres populaires", + "releases": "Sorties", + "criticsPicks": "Choix des critiques", + "playlists": "Playlists", + "artists": "Artistes", + "moreLabels": "Plus de labels", + "readMore": "LIRE PLUS", + "readLess": "LIRE MOINS", + "seeAll": "Voir tout", + "showMore": "Voir plus", + "showLess": "Voir moins" + }, "library": { "title": "Bibliothèque locale", "browse": "Parcourir la bibliothèque", diff --git a/src/lib/stores/navigationStore.ts b/src/lib/stores/navigationStore.ts index 0615738fe..520fa1ba4 100644 --- a/src/lib/stores/navigationStore.ts +++ b/src/lib/stores/navigationStore.ts @@ -16,6 +16,7 @@ export type ViewType = | 'artist' | 'musician' | 'label' + | 'label-releases' | 'playlist' | 'playlist-manager' | 'blacklist-manager' diff --git a/src/lib/stores/playbackContextStore.ts b/src/lib/stores/playbackContextStore.ts index c4030f0cd..1426d4531 100644 --- a/src/lib/stores/playbackContextStore.ts +++ b/src/lib/stores/playbackContextStore.ts @@ -9,7 +9,7 @@ import { invoke } from '@tauri-apps/api/core'; // ============ Types ============ -export type ContextType = 'album' | 'playlist' | 'artist_top' | 'home_list' | 'favorites' | 'local_library' | 'radio' | 'search'; +export type ContextType = 'album' | 'playlist' | 'artist_top' | 'label_top' | 'home_list' | 'favorites' | 'local_library' | 'radio' | 'search'; export type ContentSource = 'qobuz' | 'local' | 'plex'; export interface PlaybackContext { @@ -133,18 +133,21 @@ export function consumeContextTrackFocus( export function getContextDisplayInfo(): string | null { if (!currentContext) return null; - const typeStr = { + const typeStr: Record = { album: 'Album', playlist: 'Playlist', artist_top: 'Artist Top Songs', + label_top: 'Label Top Songs', home_list: 'Home List', favorites: 'Favorites', local_library: 'Local Library', radio: 'Radio', search: 'Search Results' - }[currentContext.type]; + }; - return `${typeStr} · ${currentContext.label}`; + const displayType = typeStr[currentContext.type]; + + return `${displayType} · ${currentContext.label}`; } /** diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index afd7f0fe2..41f3940d8 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -276,6 +276,35 @@ export interface LabelDetail { albumsFetched: number; } +/** Response from /label/page */ +export interface LabelPageData { + id: number; + name: string; + description?: string; + image?: string | Record; + releases?: LabelReleaseContainer[]; + playlists?: { has_more?: boolean; items?: Record[] }; + top_tracks?: Record[]; + top_artists?: { has_more?: boolean; items?: Record[] }; +} + +export interface LabelReleaseContainer { + id?: string; + data?: { has_more?: boolean; items?: QobuzAlbum[] }; +} + +/** Response from /label/explore */ +export interface LabelExploreResponse { + has_more?: boolean; + items?: LabelExploreItem[]; +} + +export interface LabelExploreItem { + id: number; + name: string; + image?: string | Record; +} + export interface PlaylistTrack { id: number; number: number; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 5440619b0..96c291eb8 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -315,6 +315,7 @@ import ArtistDetailView from '$lib/components/views/ArtistDetailView.svelte'; import MusicianPageView from '$lib/components/views/MusicianPageView.svelte'; import LabelView from '$lib/components/views/LabelView.svelte'; + import LabelReleasesView from '$lib/components/views/LabelReleasesView.svelte'; import PlaylistDetailView from '$lib/components/views/PlaylistDetailView.svelte'; import FavoritesView from '$lib/components/views/FavoritesView.svelte'; import LocalLibraryView from '$lib/components/views/LocalLibraryView.svelte'; @@ -795,6 +796,11 @@ navigateTo('label'); } + function handleNavigateLabelReleases(labelId: number, labelName: string) { + selectedLabel = { id: labelId, name: labelName }; + navigateTo('label-releases'); + } + /** * Handle musician click from credits * Resolves musician and routes based on confidence level: @@ -3556,6 +3562,35 @@ checkAlbumFullyDownloaded={checkAlbumFullyDownloaded} {downloadStateVersion} onArtistClick={handleArtistClick} + onLabelClick={handleLabelClick} + onNavigateReleases={handleNavigateLabelReleases} + onPlaylistClick={selectPlaylist} + onTrackPlay={handleDisplayTrackPlay} + onTrackPlayNext={queueQobuzTrackNext} + onTrackPlayLater={queueQobuzTrackLater} + onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])} + onTrackGoToAlbum={handleAlbumClick} + activeTrackId={currentTrack?.id ?? null} + isPlaybackActive={isPlaying} + /> + {:else if activeView === 'label-releases' && selectedLabel} + {:else if activeView === 'library' || activeView === 'library-album'}