feat: add LabelView to browse albums by record label
- Add get_label API endpoint and Tauri command - Create LabelView component with album grid, search, and infinite scroll - Add clickable label links in AlbumDetailView, AlbumCreditsModal, and TrackInfoModal - Add label_id to AlbumInfo and AlbumDetail types - Add label to QobuzTrack album summary for track info display
This commit is contained in:
1 parent
04197b98f0
commit
6467ca038e
13 files changed
+775
-8
No files matched your search
@@ -478,6 +478,30 @@ impl QobuzClient {
|
||||
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);
|
||||
let locale = self.locale().await;
|
||||
|
||||
let response: Value = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("X-App-Id", self.app_id().await?)
|
||||
.query(&[
|
||||
("label_id", label_id.to_string()),
|
||||
("extra", "albums".to_string()),
|
||||
("limit", limit.to_string()),
|
||||
("offset", offset.to_string()),
|
||||
("lang", locale),
|
||||
])
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::from_value(response)?)
|
||||
}
|
||||
|
||||
// === Authenticated endpoints ===
|
||||
|
||||
/// Get stream URL for a track (requires auth + signature)
|
||||
|
||||
@@ -158,6 +158,8 @@ pub struct AlbumSummary {
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub image: ImageSet,
|
||||
/// Label (if returned in track response)
|
||||
pub label: Option<Label>,
|
||||
}
|
||||
|
||||
/// Artist model
|
||||
@@ -254,13 +256,24 @@ impl ImageSet {
|
||||
}
|
||||
}
|
||||
|
||||
/// Label model
|
||||
/// Label model (basic reference)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Label {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Label detail with albums
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LabelDetail {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub image: Option<ImageSet>,
|
||||
pub albums: Option<SearchResultsPage<Album>>,
|
||||
pub albums_count: Option<u32>,
|
||||
}
|
||||
|
||||
/// Genre model
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Genre {
|
||||
|
||||
@@ -40,6 +40,7 @@ pub struct AlbumInfo {
|
||||
pub year: String,
|
||||
pub release_date: Option<String>,
|
||||
pub label: String,
|
||||
pub label_id: Option<u64>,
|
||||
pub genre: String,
|
||||
pub quality: String,
|
||||
pub track_count: u32,
|
||||
@@ -100,6 +101,7 @@ fn album_to_info(album: &Album) -> AlbumInfo {
|
||||
year,
|
||||
release_date: album.release_date_original.clone(),
|
||||
label: album.label.as_ref().map(|l| l.name.clone()).unwrap_or_default(),
|
||||
label_id: album.label.as_ref().map(|l| l.id),
|
||||
genre: album.genre.as_ref().map(|g| g.name.clone()).unwrap_or_default(),
|
||||
quality: format_quality(album.maximum_bit_depth, album.maximum_sampling_rate),
|
||||
track_count: album.tracks_count.unwrap_or(0),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::api::{Album, Artist, ArtistAlbums, SearchResultsPage, Track};
|
||||
use crate::api::{Album, Artist, ArtistAlbums, LabelDetail, SearchResultsPage, Track};
|
||||
use crate::api_cache::ApiCacheState;
|
||||
use crate::AppState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -264,3 +264,25 @@ pub async fn get_similar_artists(
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get label detail with albums
|
||||
#[tauri::command]
|
||||
pub async fn get_label(
|
||||
label_id: u64,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<LabelDetail, String> {
|
||||
log::info!(
|
||||
"Command: get_label {} limit={:?} offset={:?}",
|
||||
label_id,
|
||||
limit,
|
||||
offset
|
||||
);
|
||||
|
||||
let client = state.client.lock().await;
|
||||
client
|
||||
.get_label(label_id, limit.unwrap_or(100), offset.unwrap_or(0))
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -436,6 +436,7 @@ pub fn run() {
|
||||
commands::get_artist_detail,
|
||||
commands::get_artist_albums,
|
||||
commands::get_similar_artists,
|
||||
commands::get_label,
|
||||
// Playback commands
|
||||
commands::play_track,
|
||||
commands::prefetch_track,
|
||||
|
||||
@@ -325,6 +325,7 @@ export function convertQobuzAlbum(album: QobuzAlbum): AlbumDetail {
|
||||
year: album.release_date_original?.split('-')[0] || '',
|
||||
releaseDate: album.release_date_original,
|
||||
label: album.label?.name || '',
|
||||
labelId: album.label?.id,
|
||||
genre: album.genre?.name || '',
|
||||
quality,
|
||||
trackCount: album.tracks_count || album.tracks?.items?.length || 0,
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
onClose: () => void;
|
||||
onTrackPlay?: (track: TrackCredits) => void;
|
||||
onPerformerSearch?: (name: string) => void;
|
||||
onLabelClick?: (labelId: number, labelName: string) => void;
|
||||
}
|
||||
|
||||
let { isOpen, albumId, onClose, onTrackPlay, onPerformerSearch }: Props = $props();
|
||||
let { isOpen, albumId, onClose, onTrackPlay, onPerformerSearch, onLabelClick }: Props = $props();
|
||||
|
||||
function handlePerformerClick(name: string) {
|
||||
if (onPerformerSearch) {
|
||||
@@ -121,7 +122,19 @@
|
||||
{#if credits.album.label}
|
||||
<p class="meta-row">
|
||||
<span class="meta-label">Released by</span>
|
||||
<span class="meta-value label-name">{credits.album.label}</span>
|
||||
{#if credits.album.label_id && onLabelClick}
|
||||
<button
|
||||
class="label-link"
|
||||
onclick={() => {
|
||||
onLabelClick!(credits!.album.label_id!, credits!.album.label);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{credits.album.label}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="meta-value label-name">{credits.album.label}</span>
|
||||
{/if}
|
||||
{#if credits.album.release_date}
|
||||
<span class="meta-date">on {new Date(credits.album.release_date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}</span>
|
||||
{/if}
|
||||
@@ -448,6 +461,22 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.label-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.label-link:hover {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.meta-date {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
onClose: () => void;
|
||||
onArtistClick?: (artistId: number) => void;
|
||||
onPerformerSearch?: (name: string) => void;
|
||||
onLabelClick?: (labelId: number, labelName: string) => void;
|
||||
}
|
||||
|
||||
let { isOpen, trackId, onClose, onArtistClick, onPerformerSearch }: Props = $props();
|
||||
let { isOpen, trackId, onClose, onArtistClick, onPerformerSearch, onLabelClick }: Props = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
@@ -187,6 +188,24 @@
|
||||
<span class="metadata-value mono">{trackInfo.track.isrc}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if trackInfo.track.album?.label}
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">LABEL</span>
|
||||
{#if onLabelClick}
|
||||
<button
|
||||
class="label-link"
|
||||
onclick={() => {
|
||||
onLabelClick!(trackInfo!.track.album!.label!.id, trackInfo!.track.album!.label!.name);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{trackInfo.track.album.label.name}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="metadata-value">{trackInfo.track.album.label.name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Credits Grid (2 columns) -->
|
||||
@@ -465,4 +484,21 @@
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Label link */
|
||||
.label-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.label-link:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -44,6 +44,7 @@
|
||||
year: string;
|
||||
releaseDate?: string;
|
||||
label: string;
|
||||
labelId?: number;
|
||||
genre: string;
|
||||
quality: string;
|
||||
trackCount: number;
|
||||
@@ -52,6 +53,7 @@
|
||||
};
|
||||
onBack: () => void;
|
||||
onArtistClick?: () => void;
|
||||
onLabelClick?: (labelId: number, labelName: string) => void;
|
||||
onTrackPlay?: (track: Track) => void;
|
||||
onTrackPlayNext?: (track: Track) => void;
|
||||
onTrackPlayLater?: (track: Track) => void;
|
||||
@@ -97,6 +99,7 @@
|
||||
album,
|
||||
onBack,
|
||||
onArtistClick,
|
||||
onLabelClick,
|
||||
onTrackPlay,
|
||||
onTrackPlayNext,
|
||||
onTrackPlayLater,
|
||||
@@ -318,7 +321,17 @@
|
||||
{:else}
|
||||
<div class="artist-name">{album.artist}</div>
|
||||
{/if}
|
||||
<div class="album-info">{formattedReleaseDate} • {album.label} • {album.genre}</div>
|
||||
<div class="album-info">
|
||||
{formattedReleaseDate} •
|
||||
{#if album.labelId && onLabelClick}
|
||||
<button class="label-link" onclick={() => onLabelClick!(album.labelId!, album.label)}>
|
||||
{album.label}
|
||||
</button>
|
||||
{:else}
|
||||
{album.label}
|
||||
{/if}
|
||||
• {album.genre}
|
||||
</div>
|
||||
<div class="album-quality">{album.quality}</div>
|
||||
<div class="album-stats">{album.trackCount} tracks • {album.duration}</div>
|
||||
|
||||
@@ -608,6 +621,21 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.label-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.label-link:hover {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.album-info {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
<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;
|
||||
}
|
||||
|
||||
let {
|
||||
labelId,
|
||||
labelName = '',
|
||||
onBack,
|
||||
onAlbumClick,
|
||||
onAlbumPlay,
|
||||
onAlbumPlayNext,
|
||||
onAlbumPlayLater,
|
||||
onAddAlbumToPlaylist,
|
||||
onAlbumShareQobuz,
|
||||
onAlbumShareSonglink,
|
||||
onAlbumDownload,
|
||||
onOpenAlbumFolder,
|
||||
onReDownloadAlbum,
|
||||
checkAlbumFullyDownloaded,
|
||||
downloadStateVersion
|
||||
}: 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);
|
||||
|
||||
// Download status tracking
|
||||
let albumOfflineCacheStatuses = $state<Map<string, boolean>>(new Map());
|
||||
|
||||
// Derived: filtered albums based on search
|
||||
let filteredAlbums = $derived(
|
||||
searchQuery.trim()
|
||||
? albums.filter(album =>
|
||||
album.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
album.artist?.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: 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;
|
||||
}>('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 };
|
||||
}>('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 clearSearch() {
|
||||
searchQuery = '';
|
||||
}
|
||||
|
||||
function toggleSearch() {
|
||||
searchExpanded = !searchExpanded;
|
||||
if (!searchExpanded) {
|
||||
searchQuery = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll handler for infinite loading
|
||||
function handleScroll(e: Event) {
|
||||
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 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}
|
||||
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 filteredAlbums.length === 0}
|
||||
<div class="empty">
|
||||
<Search size={48} />
|
||||
<p>No albums match "{searchQuery}"</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="album-grid">
|
||||
{#each filteredAlbums as album (album.id)}
|
||||
<AlbumCard
|
||||
albumId={album.id}
|
||||
artwork={album.image?.large || album.image?.thumbnail || ''}
|
||||
title={album.title}
|
||||
artist={album.artist?.name || 'Unknown Artist'}
|
||||
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>
|
||||
@@ -14,6 +14,7 @@ export type ViewType =
|
||||
| 'settings'
|
||||
| 'album'
|
||||
| 'artist'
|
||||
| 'label'
|
||||
| 'playlist'
|
||||
| 'playlist-manager'
|
||||
| 'favorites-tracks'
|
||||
|
||||
+14
-1
@@ -21,6 +21,7 @@ export interface QobuzTrack {
|
||||
id?: string;
|
||||
title: string;
|
||||
image?: QobuzImage;
|
||||
label?: { id: number; name: string };
|
||||
};
|
||||
performer?: { id?: number; name: string };
|
||||
hires_streamable?: boolean;
|
||||
@@ -59,6 +60,7 @@ export interface AlbumInfo {
|
||||
year: string;
|
||||
release_date?: string;
|
||||
label: string;
|
||||
label_id?: number;
|
||||
genre: string;
|
||||
quality: string;
|
||||
track_count: number;
|
||||
@@ -90,7 +92,7 @@ export interface QobuzAlbum {
|
||||
hires_streamable?: boolean;
|
||||
tracks_count?: number;
|
||||
duration?: number;
|
||||
label?: { name: string };
|
||||
label?: { id?: number; name: string };
|
||||
genre?: { name: string };
|
||||
maximum_bit_depth?: number;
|
||||
maximum_sampling_rate?: number;
|
||||
@@ -161,6 +163,7 @@ export interface AlbumDetail {
|
||||
year: string;
|
||||
releaseDate?: string; // Full date in YYYY-MM-DD format
|
||||
label: string;
|
||||
labelId?: number;
|
||||
genre: string;
|
||||
quality: string;
|
||||
trackCount: number;
|
||||
@@ -232,6 +235,16 @@ export interface ArtistDetail {
|
||||
albumsFetched: number;
|
||||
}
|
||||
|
||||
export interface LabelDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
image?: QobuzImage;
|
||||
albums: QobuzAlbum[];
|
||||
totalAlbums: number;
|
||||
albumsFetched: number;
|
||||
}
|
||||
|
||||
export interface PlaylistTrack {
|
||||
id: number;
|
||||
number: number;
|
||||
|
||||
+30
-1
@@ -177,6 +177,7 @@
|
||||
Track,
|
||||
AlbumDetail,
|
||||
ArtistDetail,
|
||||
LabelDetail,
|
||||
PlaylistTrack,
|
||||
DisplayTrack,
|
||||
LocalLibraryTrack,
|
||||
@@ -274,6 +275,7 @@
|
||||
import SettingsView from '$lib/components/views/SettingsView.svelte';
|
||||
import AlbumDetailView from '$lib/components/views/AlbumDetailView.svelte';
|
||||
import ArtistDetailView from '$lib/components/views/ArtistDetailView.svelte';
|
||||
import LabelView from '$lib/components/views/LabelView.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';
|
||||
@@ -318,9 +320,10 @@
|
||||
// View State (from navigationStore subscription)
|
||||
let activeView = $state<ViewType>('home');
|
||||
let selectedPlaylistId = $state<number | null>(null);
|
||||
// Album and Artist data are fetched, so kept local
|
||||
// Album, Artist and Label data are fetched, so kept local
|
||||
let selectedAlbum = $state<AlbumDetail | null>(null);
|
||||
let selectedArtist = $state<ArtistDetail | null>(null);
|
||||
let selectedLabel = $state<{ id: number; name: string } | null>(null);
|
||||
let isArtistAlbumsLoading = $state(false);
|
||||
|
||||
// Artist albums for "By the same artist" section in album view
|
||||
@@ -496,6 +499,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleLabelClick(labelId: number, labelName?: string) {
|
||||
selectedLabel = { id: labelId, name: labelName || '' };
|
||||
navigateTo('label');
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a performer by name (from track credits)
|
||||
*/
|
||||
@@ -2498,6 +2506,7 @@
|
||||
isPlaybackActive={isPlaying}
|
||||
onBack={navGoBack}
|
||||
onArtistClick={() => selectedAlbum?.artistId && handleArtistClick(selectedAlbum.artistId)}
|
||||
onLabelClick={handleLabelClick}
|
||||
onTrackPlay={handleAlbumTrackPlay}
|
||||
onTrackPlayNext={handleAlbumTrackPlayNext}
|
||||
onTrackPlayLater={handleAlbumTrackPlayLater}
|
||||
@@ -2566,6 +2575,24 @@
|
||||
activeTrackId={currentTrack?.id ?? null}
|
||||
isPlaybackActive={isPlaying}
|
||||
/>
|
||||
{:else if activeView === 'label' && selectedLabel}
|
||||
<LabelView
|
||||
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}
|
||||
/>
|
||||
{:else if activeView === 'library' || activeView === 'library-album'}
|
||||
<LocalLibraryView
|
||||
onTrackPlay={handleLocalTrackPlay}
|
||||
@@ -2887,6 +2914,7 @@
|
||||
trackInfoTrackId = null;
|
||||
}}
|
||||
onArtistClick={handleArtistClick}
|
||||
onLabelClick={handleLabelClick}
|
||||
/>
|
||||
|
||||
<!-- Album Credits Modal -->
|
||||
@@ -2906,6 +2934,7 @@
|
||||
}
|
||||
}
|
||||
}}
|
||||
onLabelClick={handleLabelClick}
|
||||
/>
|
||||
|
||||
<!-- Cast Picker -->
|
||||
|
||||
Reference in new issue
Block a user