feat(label): redesign label detail view with Android-style sections

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).
This commit is contained in:
vicrodh committed 2026-02-20 02:51:47 -06:00
1 parent 318318a3ab
commit 585053bd42
21 files changed
+2260 -424

No files matched your search

+28 -1
View File
@@ -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<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
.map_err(CoreError::Api)
}
/// Get label page (aggregated: top tracks, releases, playlists, artists)
pub async fn get_label_page(&self, label_id: u64) -> Result<LabelPageData, CoreError> {
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<LabelExploreResponse, CoreError> {
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
+2 -1
View File
@@ -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,
+41
View File
@@ -353,6 +353,47 @@ pub struct LabelDetail {
pub albums_count: Option<u32>,
}
// ============ 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<String>,
#[serde(default)]
pub image: Option<serde_json::Value>,
#[serde(default)]
pub releases: Option<Vec<LabelPageContainer>>,
#[serde(default)]
pub playlists: Option<LabelPageGenericList>,
#[serde(default)]
pub top_tracks: Option<Vec<serde_json::Value>>,
#[serde(default)]
pub top_artists: Option<LabelPageGenericList>,
}
/// A container within label page (e.g. releases category)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LabelPageContainer {
pub id: Option<String>,
pub data: Option<LabelPageGenericList>,
}
/// Generic list with has_more and items
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LabelPageGenericList {
pub has_more: Option<bool>,
pub items: Option<Vec<serde_json::Value>>,
}
/// Response from /label/explore
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LabelExploreResponse {
pub has_more: Option<bool>,
pub items: Option<Vec<serde_json::Value>>,
}
/// Genre model
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Genre {
+43
View File
@@ -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<LabelPageData> {
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<LabelExploreResponse> {
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<LabelDetail> {
let url = endpoints::build_url(paths::LABEL_GET);
+2
View File
@@ -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";
+43
View File
@@ -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<LabelPageData> {
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<LabelExploreResponse> {
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<LabelDetail> {
let url = endpoints::build_url(paths::LABEL_GET);
+2
View File
@@ -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";
+41
View File
@@ -445,6 +445,47 @@ pub struct LabelDetail {
pub albums_count: Option<u32>,
}
// ============ 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<String>,
#[serde(default)]
pub image: Option<serde_json::Value>,
#[serde(default)]
pub releases: Option<Vec<LabelPageContainer>>,
#[serde(default)]
pub playlists: Option<LabelPageGenericList>,
#[serde(default)]
pub top_tracks: Option<Vec<serde_json::Value>>,
#[serde(default)]
pub top_artists: Option<LabelPageGenericList>,
}
/// A container within label page (e.g. releases category)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LabelPageContainer {
pub id: Option<String>,
pub data: Option<LabelPageGenericList>,
}
/// Generic list with has_more and items
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LabelPageGenericList {
pub has_more: Option<bool>,
pub items: Option<Vec<serde_json::Value>>,
}
/// Response from /label/explore
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LabelExploreResponse {
pub has_more: Option<bool>,
pub items: Option<Vec<serde_json::Value>>,
}
/// Genre model (basic, used in album responses)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Genre {
+45 -1
View File
@@ -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<LabelPageData, RuntimeError> {
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<LabelExploreResponse, RuntimeError> {
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.
+22 -1
View File
@@ -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<LabelPageData, String> {
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<LabelExploreResponse, String> {
self.core
.get_label_explore(limit, offset)
.await
.map_err(|e| e.to_string())
}
// ==================== Streaming ====================
/// Get stream URL for a track with quality fallback
+2
View File
@@ -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,
@@ -0,0 +1,623 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { onMount } from 'svelte';
import { ArrowLeft, Search, X, Disc3, Loader2 } from 'lucide-svelte';
import AlbumCard from '../AlbumCard.svelte';
import type { QobuzAlbum, LabelDetail } from '$lib/types';
import type { OfflineCacheStatus } from '$lib/stores/offlineCacheState';
interface Props {
labelId: number;
labelName?: string;
onBack: () => void;
onAlbumClick?: (albumId: string) => void;
onAlbumPlay?: (albumId: string) => void;
onAlbumPlayNext?: (albumId: string) => void;
onAlbumPlayLater?: (albumId: string) => void;
onAddAlbumToPlaylist?: (albumId: string) => void;
onAlbumShareQobuz?: (albumId: string) => void;
onAlbumShareSonglink?: (albumId: string) => void;
onAlbumDownload?: (albumId: string) => void;
onOpenAlbumFolder?: (albumId: string) => void;
onReDownloadAlbum?: (albumId: string) => void;
checkAlbumFullyDownloaded?: (albumId: string) => Promise<boolean>;
downloadStateVersion?: number;
onArtistClick?: (artistId: number) => void;
}
let {
labelId,
labelName = '',
onBack,
onAlbumClick,
onAlbumPlay,
onAlbumPlayNext,
onAlbumPlayLater,
onAddAlbumToPlaylist,
onAlbumShareQobuz,
onAlbumShareSonglink,
onAlbumDownload,
onOpenAlbumFolder,
onReDownloadAlbum,
checkAlbumFullyDownloaded,
downloadStateVersion,
onArtistClick
}: Props = $props();
// State
let label = $state<LabelDetail | null>(null);
let albums = $state<QobuzAlbum[]>([]);
let loading = $state(false);
let loadingMore = $state(false);
let error = $state<string | null>(null);
let searchQuery = $state('');
let searchExpanded = $state(false);
let totalAlbums = $state(0);
let albumsFetched = $state(0);
// API search state
let apiSearchResults = $state<QobuzAlbum[] | null>(null);
let apiSearching = $state(false);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
let searchVersion = 0;
// Download status tracking
let albumOfflineCacheStatuses = $state<Map<string, boolean>>(new Map());
// When API search is active, show API results; otherwise show all loaded albums
let displayAlbums = $derived(apiSearchResults !== null ? apiSearchResults : albums);
async function loadLabel() {
loading = true;
error = null;
try {
const result = await invoke<{
id: number;
name: string;
description?: string;
image?: { small?: string; thumbnail?: string; large?: string };
albums?: { items: QobuzAlbum[]; total: number; offset: number; limit: number };
albums_count?: number;
}>('v2_get_label', { labelId, limit: 100, offset: 0 });
label = {
id: result.id,
name: result.name,
description: result.description,
image: result.image,
albums: result.albums?.items ?? [],
totalAlbums: result.albums?.total ?? result.albums_count ?? 0,
albumsFetched: result.albums?.items?.length ?? 0
};
albums = label.albums;
totalAlbums = label.totalAlbums;
albumsFetched = label.albumsFetched;
// Load download statuses
await loadAllAlbumOfflineCacheStatuses(albums);
} catch (e) {
console.error('Failed to load label:', e);
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
async function loadMore() {
if (loadingMore || albumsFetched >= totalAlbums) return;
loadingMore = true;
try {
const result = await invoke<{
id: number;
name: string;
albums?: { items: QobuzAlbum[]; total: number; offset: number; limit: number };
}>('v2_get_label', { labelId, limit: 100, offset: albumsFetched });
const newAlbums = result.albums?.items ?? [];
albums = [...albums, ...newAlbums];
albumsFetched += newAlbums.length;
// Load download statuses for new albums
await loadAllAlbumOfflineCacheStatuses(newAlbums);
} catch (e) {
console.error('Failed to load more albums:', e);
} finally {
loadingMore = false;
}
}
async function loadAlbumOfflineCacheStatus(albumId: string) {
if (!checkAlbumFullyDownloaded) return false;
try {
const isDownloaded = await checkAlbumFullyDownloaded(albumId);
albumOfflineCacheStatuses.set(albumId, isDownloaded);
return isDownloaded;
} catch {
return false;
}
}
async function loadAllAlbumOfflineCacheStatuses(albumList: QobuzAlbum[]) {
if (!checkAlbumFullyDownloaded || albumList.length === 0) return;
await Promise.all(albumList.map(album => loadAlbumOfflineCacheStatus(album.id)));
}
function isAlbumDownloaded(albumId: string): boolean {
void downloadStateVersion;
return albumOfflineCacheStatuses.get(albumId) || false;
}
function getQualityLabel(album: QobuzAlbum): string {
if (album.hires_streamable) {
const bitDepth = album.maximum_bit_depth || 24;
const sampleRate = album.maximum_sampling_rate || 96;
return `${bitDepth}-bit/${sampleRate}kHz`;
}
return '';
}
function getGenreLabel(album: QobuzAlbum): string {
return album.genre?.name || '';
}
function clearSearch() {
searchQuery = '';
apiSearchResults = null;
apiSearching = false;
if (searchTimeout) clearTimeout(searchTimeout);
}
function toggleSearch() {
searchExpanded = !searchExpanded;
if (!searchExpanded) {
searchQuery = '';
apiSearchResults = null;
apiSearching = false;
if (searchTimeout) clearTimeout(searchTimeout);
}
}
function handleSearchInput() {
if (searchTimeout) clearTimeout(searchTimeout);
const query = searchQuery.trim();
if (query.length < 2) {
apiSearchResults = null;
apiSearching = false;
return;
}
apiSearching = true;
searchTimeout = setTimeout(() => performLabelSearch(query), 300);
}
async function performLabelSearch(query: string) {
searchVersion++;
const thisVersion = searchVersion;
try {
const results = await invoke<{ items: QobuzAlbum[]; total: number; offset: number; limit: number }>(
'v2_search_albums', { query, limit: 200, offset: 0, searchType: null }
);
if (thisVersion !== searchVersion) return;
apiSearchResults = results.items.filter(
album => album.label?.id === labelId
);
} catch (e) {
console.error('Label search failed:', e);
if (thisVersion === searchVersion) apiSearchResults = [];
} finally {
if (thisVersion === searchVersion) apiSearching = false;
}
}
// Scroll handler for infinite loading (disabled during API search)
function handleScroll(e: Event) {
if (apiSearchResults !== null) return;
const target = e.target as HTMLElement;
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
if (scrollBottom < 400 && !loadingMore && albumsFetched < totalAlbums) {
loadMore();
}
}
onMount(() => {
loadLabel();
});
</script>
<div class="label-view" onscroll={handleScroll}>
<!-- Header -->
<header class="header">
<button class="back-btn" onclick={onBack} title="Go back">
<ArrowLeft size={20} />
</button>
<div class="header-icon">
<Disc3 size={36} strokeWidth={1.5} />
</div>
<div class="header-content">
<h1>{label?.name || labelName || 'Label'}</h1>
<p class="subtitle">
{#if loading}
Loading...
{:else if totalAlbums > 0}
{totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
{/if}
</p>
</div>
</header>
<!-- Fixed Navigation/Search Bar -->
<nav class="label-nav">
<div class="nav-left">
<span class="nav-title">Albums</span>
{#if apiSearchResults !== null}
<span class="nav-count">{apiSearchResults.length} result{apiSearchResults.length !== 1 ? 's' : ''}</span>
{:else if albumsFetched > 0 && albumsFetched < totalAlbums}
<span class="nav-count">Showing {albumsFetched} of {totalAlbums}</span>
{/if}
</div>
<div class="nav-right">
{#if searchExpanded}
<div class="search-expanded">
<Search size={16} class="search-icon-inline" />
<input
type="text"
class="search-input-inline"
placeholder="Search albums..."
bind:value={searchQuery}
oninput={handleSearchInput}
autofocus
/>
{#if searchQuery}
<button class="search-clear-btn" onclick={clearSearch}>
<X size={14} />
</button>
{/if}
<button class="search-close-btn" onclick={toggleSearch}>
<X size={16} />
</button>
</div>
{:else}
<button class="search-icon-btn" onclick={toggleSearch} title="Search albums">
<Search size={18} />
</button>
{/if}
</div>
</nav>
<!-- Content -->
<div class="content">
{#if loading}
<div class="loading">
<div class="spinner"></div>
<p>Loading label...</p>
</div>
{:else if error}
<div class="error">
<p>Failed to load label</p>
<p class="error-detail">{error}</p>
<button class="retry-btn" onclick={loadLabel}>Retry</button>
</div>
{:else if albums.length === 0}
<div class="empty">
<Disc3 size={48} />
<p>No albums found for this label</p>
</div>
{:else if apiSearching}
<div class="loading">
<div class="spinner"></div>
<p>Searching...</p>
</div>
{:else if displayAlbums.length === 0}
<div class="empty">
<Search size={48} />
<p>No albums match "{searchQuery}"</p>
</div>
{:else}
<div class="album-grid">
{#each displayAlbums as album (album.id)}
<AlbumCard
albumId={album.id}
artwork={album.image?.large || album.image?.thumbnail || ''}
title={album.title}
artist={album.artist?.name || 'Unknown Artist'}
artistId={album.artist?.id}
onArtistClick={onArtistClick}
genre={getGenreLabel(album)}
releaseDate={album.release_date_original}
quality={getQualityLabel(album)}
size="large"
onclick={() => 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}
</div>
{#if loadingMore}
<div class="loading-more">
<Loader2 size={20} class="spinner-icon" />
<span>Loading more albums...</span>
</div>
{/if}
{/if}
</div>
</div>
<style>
.label-view {
padding: 24px;
padding-left: 18px;
padding-right: 8px;
padding-bottom: 100px;
overflow-y: auto;
height: 100%;
}
/* Custom scrollbar */
.label-view::-webkit-scrollbar {
width: 6px;
}
.label-view::-webkit-scrollbar-track {
background: transparent;
}
.label-view::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 3px;
}
.label-view::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 32px;
}
.back-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 150ms ease;
}
.back-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.header-icon {
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border-radius: 16px;
color: white;
}
.header-content {
flex: 1;
}
.header-content h1 {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 4px 0;
}
.subtitle {
font-size: 14px;
color: var(--text-muted);
margin: 0;
}
.label-nav {
position: sticky;
top: -24px;
z-index: 4;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 12px 24px;
margin: 0 -8px 16px -24px;
width: calc(100% + 32px);
background: var(--bg-primary);
border-bottom: 1px solid var(--alpha-6);
box-shadow: 0 4px 8px -4px rgba(0, 0, 0, 0.5);
}
.nav-left {
display: flex;
align-items: center;
gap: 12px;
}
.nav-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.nav-count {
font-size: 12px;
color: var(--text-muted);
}
.nav-right {
display: flex;
align-items: center;
gap: 8px;
}
.search-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: none;
color: var(--text-muted);
cursor: pointer;
border-radius: 6px;
transition: all 150ms ease;
}
.search-icon-btn:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.search-expanded {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--bg-secondary);
border: 1px solid var(--bg-tertiary);
border-radius: 8px;
min-width: 280px;
}
:global(.search-icon-inline) {
color: var(--text-muted);
flex-shrink: 0;
}
.search-input-inline {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-size: 13px;
}
.search-input-inline::placeholder {
color: var(--text-muted);
}
.search-clear-btn,
.search-close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
background: none;
color: var(--text-muted);
cursor: pointer;
border-radius: 4px;
transition: all 150ms ease;
flex-shrink: 0;
}
.search-clear-btn:hover,
.search-close-btn:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.content {
min-height: 200px;
}
.loading,
.error,
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
color: var(--text-muted);
text-align: center;
}
.loading p,
.error p,
.empty p {
margin: 16px 0 0 0;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--bg-tertiary);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-detail {
font-size: 12px;
margin-top: 8px;
}
.retry-btn {
margin-top: 16px;
padding: 8px 24px;
background-color: var(--accent-primary);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
.retry-btn:hover {
opacity: 0.9;
}
.album-grid {
display: flex;
flex-wrap: wrap;
gap: 24px 14px;
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px;
color: var(--text-muted);
font-size: 13px;
}
:global(.spinner-icon) {
animation: spin 1s linear infinite;
}
</style>
File diff suppressed because it is too large. Load diff
+14
View File
@@ -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",
+14
View File
@@ -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",
+14
View File
@@ -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",
+14
View File
@@ -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",
+1
View File
@@ -16,6 +16,7 @@ export type ViewType =
| 'artist'
| 'musician'
| 'label'
| 'label-releases'
| 'playlist'
| 'playlist-manager'
| 'blacklist-manager'
+7 -4
View File
@@ -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<ContextType, string> = {
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}`;
}
/**
+29
View File
@@ -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<string, string>;
releases?: LabelReleaseContainer[];
playlists?: { has_more?: boolean; items?: Record<string, unknown>[] };
top_tracks?: Record<string, unknown>[];
top_artists?: { has_more?: boolean; items?: Record<string, unknown>[] };
}
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<string, string>;
}
export interface PlaylistTrack {
id: number;
number: number;
+35
View File
@@ -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}
<LabelReleasesView
labelId={selectedLabel.id}
labelName={selectedLabel.name}
onBack={navGoBack}
onAlbumClick={handleAlbumClick}
onAlbumPlay={playAlbumById}
onAlbumPlayNext={queueAlbumNextById}
onAlbumPlayLater={queueAlbumLaterById}
onAddAlbumToPlaylist={addAlbumToPlaylistById}
onAlbumShareQobuz={shareAlbumQobuzLinkById}
onAlbumShareSonglink={shareAlbumSonglinkById}
onAlbumDownload={downloadAlbumById}
onOpenAlbumFolder={openAlbumFolderById}
onReDownloadAlbum={reDownloadAlbumById}
checkAlbumFullyDownloaded={checkAlbumFullyDownloaded}
{downloadStateVersion}
onArtistClick={handleArtistClick}
/>
{:else if activeView === 'library' || activeView === 'library-album'}
<LocalLibraryView