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:
vicrodh committed 2026-01-25 11:28:51 -06:00
1 parent 04197b98f0
commit 6467ca038e
13 files changed
+775 -8

No files matched your search

+24
View File
@@ -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)
+14 -1
View File
@@ -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 {
+2
View File
@@ -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),
+23 -1
View File
@@ -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())
}
+1
View File
@@ -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,
+1
View File
@@ -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,
+31 -2
View File
@@ -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);
+37 -1
View File
@@ -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);
+568
View File
@@ -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>
+1
View File
@@ -14,6 +14,7 @@ export type ViewType =
| 'settings'
| 'album'
| 'artist'
| 'label'
| 'playlist'
| 'playlist-manager'
| 'favorites-tracks'
+14 -1
View File
@@ -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
View File
@@ -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 -->