Files
qbz/src/lib/components/views/ArtistDetailView.svelte
T
vicrodh cec4c32bcc chore: remove unused CSS selectors from Svelte components
Clean up dead CSS that remained after HTML refactors:
- NowPlayingBar, Sidebar, PlaylistManagerView, FavoritesView
- PlaylistModal, LocalLibraryView, PlaylistDetailView
- Dropdown, MigrationModal, AlbumCard, QualityBadge
- SearchPlaylistCard, LocalLibraryTagEditorModal
- TrackReplacementModal, ImmersiveHeader, SuggestionsPanel
- LyricsFocusPanel, ExpandedPlayer, FocusMode
- ArtistDetailView, SearchView, SettingsView
2026-02-02 22:51:42 -06:00

3520 lines
108 KiB
Svelte

<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { ArrowLeft, User, ChevronDown, ChevronUp, Play, Music, Heart, Search, X, ChevronLeft, ChevronRight, Radio, MoreHorizontal, Info, Disc, Ban } from 'lucide-svelte';
import {
isBlacklisted,
addToBlacklist,
removeFromBlacklist,
subscribe as subscribeBlacklist
} from '$lib/stores/artistBlacklistStore';
import { showToast } from '$lib/stores/toastStore';
import type { ArtistDetail, QobuzArtist } from '$lib/types';
import AlbumCard from '../AlbumCard.svelte';
import TrackMenu from '../TrackMenu.svelte';
import QualityBadge from '../QualityBadge.svelte';
import { consumeContextTrackFocus, setPlaybackContext, getPlaybackContext } from '$lib/stores/playbackContextStore';
import { togglePlay } from '$lib/stores/playerStore';
import { getQueue, syncQueueState, playQueueIndex } from '$lib/stores/queueStore';
import { subscribeContentSidebar, toggleContentSidebar, type ContentSidebarType } from '$lib/stores/sidebarStore';
import { tick, onMount, onDestroy } from 'svelte';
interface Track {
id: number;
title: string;
duration: number;
album?: {
id: string;
title: string;
image?: { small?: string; thumbnail?: string; large?: string };
};
performer?: { id?: number; name: string };
hires_streamable?: boolean;
maximum_bit_depth?: number;
maximum_sampling_rate?: number;
isrc?: string;
}
interface SearchResults {
items: Track[];
total: number;
}
interface DisplayTrack {
id: number;
title: string;
artist: string;
album: string;
albumArt: string;
duration: string;
durationSeconds: number;
hires?: boolean;
bitDepth?: number;
samplingRate?: number;
albumId?: string;
artistId?: number;
isrc?: string;
}
interface Props {
artist: ArtistDetail;
onBack: () => void;
onAlbumClick?: (albumId: string) => void;
onAlbumPlay?: (albumId: string) => void;
onAlbumPlayNext?: (albumId: string) => void;
onAlbumPlayLater?: (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;
onLoadMore?: () => void;
isLoadingMore?: boolean;
onPlaylistClick?: (playlistId: number) => void;
onTrackPlay?: (track: DisplayTrack) => void;
onTrackPlayNext?: (track: Track) => void;
onTrackPlayLater?: (track: Track) => void;
onTrackAddFavorite?: (trackId: number) => void;
onTrackAddToPlaylist?: (trackId: number) => void;
onAddAlbumToPlaylist?: (albumId: string) => void;
onTrackShareQobuz?: (trackId: number) => void;
onTrackShareSonglink?: (track: Track) => void;
onTrackGoToAlbum?: (albumId: string) => void;
onTrackGoToArtist?: (artistId: number) => void;
onLabelClick?: (labelId: number, labelName?: string) => void;
onMusicianClick?: (name: string, role: string) => void;
activeTrackId?: number | null;
isPlaybackActive?: boolean;
}
let {
artist,
onBack,
onAlbumClick,
onAlbumPlay,
onAlbumPlayNext,
onAlbumPlayLater,
onAlbumShareQobuz,
onAlbumShareSonglink,
onAlbumDownload,
onOpenAlbumFolder,
onReDownloadAlbum,
checkAlbumFullyDownloaded,
downloadStateVersion,
onLoadMore,
isLoadingMore = false,
onPlaylistClick,
onTrackPlay,
onTrackPlayNext,
onTrackPlayLater,
onTrackAddFavorite,
onTrackAddToPlaylist,
onAddAlbumToPlaylist,
onTrackShareQobuz,
onTrackShareSonglink,
onTrackGoToAlbum,
onTrackGoToArtist,
onLabelClick,
onMusicianClick,
activeTrackId = null,
isPlaybackActive = false
}: Props = $props();
let bioExpanded = $state(false);
let imageError = $state(false);
let topTracks = $state<Track[]>([]);
let tracksLoading = $state(false);
let isFavorite = $state(false);
let isFavoriteLoading = $state(false);
let isRadioLoading = $state(false);
let artistIsBlacklisted = $state(false);
let isBlacklistLoading = $state(false);
let radioLoadingMessage = $state('');
let radioJustCreated = $state(false);
let showNetworkSidebar = $state(false);
let unsubscribeSidebar: (() => void) | null = null;
let unsubscribeBlacklist: (() => void) | null = null;
function updateBlacklistState() {
artistIsBlacklisted = isBlacklisted(artist.id);
}
onMount(() => {
unsubscribeSidebar = subscribeContentSidebar((active: ContentSidebarType) => {
showNetworkSidebar = active === 'network';
});
// Initialize blacklist state and subscribe to changes
updateBlacklistState();
unsubscribeBlacklist = subscribeBlacklist(() => {
updateBlacklistState();
});
});
onDestroy(() => {
unsubscribeSidebar?.();
unsubscribeBlacklist?.();
});
let similarArtists = $state<QobuzArtist[]>([]);
let similarArtistsLoading = $state(false);
let similarArtistImageErrors = $state<Set<number>>(new Set());
// MusicBrainz relationships (Stage 3)
interface RelatedArtist {
mbid: string;
name: string;
role?: string;
period?: { begin?: string; end?: string };
ended: boolean;
}
interface ArtistRelationships {
members: RelatedArtist[];
pastMembers: RelatedArtist[];
groups: RelatedArtist[];
collaborators: RelatedArtist[];
}
interface GroupedMember {
mbid: string;
name: string;
roles: string[];
period?: { begin?: string; end?: string };
ended: boolean;
}
let mbRelationships = $state<ArtistRelationships | null>(null);
let mbRelationshipsLoading = $state(false);
let mbArtistMbid = $state<string | null>(null);
let mbAvailable = $state(true); // Assume available until proven otherwise
let artistDetailEl = $state<HTMLDivElement | null>(null);
let aboutSection = $state<HTMLDivElement | null>(null);
let topTracksSection = $state<HTMLDivElement | null>(null);
let discographySection = $state<HTMLDivElement | null>(null);
let epsSinglesSection = $state<HTMLDivElement | null>(null);
let liveAlbumsSection = $state<HTMLDivElement | null>(null);
let compilationsSection = $state<HTMLDivElement | null>(null);
let tributesSection = $state<HTMLDivElement | null>(null);
let othersSection = $state<HTMLDivElement | null>(null);
let playlistsSection = $state<HTMLDivElement | null>(null);
let activeJumpSection = $state('about');
let jumpObserver: IntersectionObserver | null = null;
// Page search state
let searchOpen = $state(false);
let searchQuery = $state('');
let searchInputEl = $state<HTMLInputElement | null>(null);
let currentSearchIndex = $state(0);
// Album sorting state - independent per section
const sortOptions = ['default', 'newest', 'oldest', 'title-asc', 'title-desc'] as const;
type AlbumSortMode = typeof sortOptions[number];
const STORAGE_SORT_KEYS = {
ALBUMS: 'qbz-artist-albums-sort',
EPS_SINGLES: 'qbz-artist-eps-singles-sort',
LIVE_ALBUMS: 'qbz-artist-live-albums-sort',
COMPILATIONS: 'qbz-artist-compilations-sort',
TRIBUTES: 'qbz-artist-tributes-sort',
OTHERS: 'qbz-artist-others-sort'
} as const;
let albumSortMode = $state<AlbumSortMode>(loadAlbumSortMode(STORAGE_SORT_KEYS.ALBUMS));
let showAlbumSortMenu = $state(false);
let epsSinglesSortMode = $state<AlbumSortMode>(loadAlbumSortMode(STORAGE_SORT_KEYS.EPS_SINGLES));
let showEpsSinglesSortMenu = $state(false);
let liveAlbumsSortMode = $state<AlbumSortMode>(loadAlbumSortMode(STORAGE_SORT_KEYS.LIVE_ALBUMS));
let showLiveAlbumsSortMenu = $state(false);
let compilationsSortMode = $state<AlbumSortMode>(loadAlbumSortMode(STORAGE_SORT_KEYS.COMPILATIONS));
let showCompilationsSortMenu = $state(false);
let tributesSortMode = $state<AlbumSortMode>(loadAlbumSortMode(STORAGE_SORT_KEYS.TRIBUTES));
let showTributesSortMenu = $state(false);
let tributesExpanded = $state(false); // Collapsed by default
let tributesVisibleCount = $state(20); // Load 20 at a time
let othersSortMode = $state<AlbumSortMode>(loadAlbumSortMode(STORAGE_SORT_KEYS.OTHERS));
let showOthersSortMenu = $state(false);
// Popular tracks display state
let visibleTracksCount = $state(5);
let showTracksContextMenu = $state(false);
function loadAlbumSortMode(key: string, fallback: AlbumSortMode = 'default'): AlbumSortMode {
try {
const value = localStorage.getItem(key);
if (value && sortOptions.includes(value as AlbumSortMode)) {
return value as AlbumSortMode;
}
} catch {
return fallback;
}
return fallback;
}
// Computed visible tracks
let visibleTracks = $derived(topTracks.slice(0, visibleTracksCount));
let canLoadMoreTracks = $derived(visibleTracksCount < 50 && topTracks.length > visibleTracksCount);
function loadMoreTracks() {
if (visibleTracksCount === 5) {
visibleTracksCount = 20;
} else if (visibleTracksCount === 20) {
visibleTracksCount = 50;
}
}
// Download status tracking
let albumDownloadStatuses = $state<Map<string, boolean>>(new Map());
async function loadAlbumDownloadStatus(albumId: string) {
if (!checkAlbumFullyDownloaded) return false;
try {
const isDownloaded = await checkAlbumFullyDownloaded(albumId);
albumDownloadStatuses.set(albumId, isDownloaded);
albumDownloadStatuses = albumDownloadStatuses;
return isDownloaded;
} catch {
return false;
}
}
async function loadAllAlbumDownloadStatuses(albums: { id: string }[]) {
if (!checkAlbumFullyDownloaded || albums.length === 0) return;
await Promise.all(albums.map(album => loadAlbumDownloadStatus(album.id)));
}
function isAlbumDownloaded(albumId: string): boolean {
void downloadStateVersion;
return albumDownloadStatuses.get(albumId) || false;
}
$effect(() => {
if (downloadStateVersion !== undefined) {
const allAlbums = [
...artist.albums,
...artist.epsSingles,
...artist.liveAlbums,
...artist.compilations,
...artist.tributes,
...artist.others
];
loadAllAlbumDownloadStatuses(allAlbums);
}
});
interface SimilarArtistsPage {
items: QobuzArtist[];
total: number;
offset: number;
limit: number;
}
$effect(() => {
const artistId = artist.id;
const artistName = artist.name;
if (!artistId || !artistName) return;
bioExpanded = false;
isBioTruncated = false;
imageError = false;
topTracks = [];
similarArtists = [];
similarArtistImageErrors = new Set();
activeJumpSection = 'about';
tributesExpanded = false;
tributesVisibleCount = 20;
loadTopTracks();
loadSimilarArtists();
loadMusicBrainzRelationships();
checkFavoriteStatus();
loadArtistAlbumDownloadStatuses();
});
// Persist sort mode changes to localStorage
$effect(() => {
try {
localStorage.setItem(STORAGE_SORT_KEYS.ALBUMS, albumSortMode);
localStorage.setItem(STORAGE_SORT_KEYS.EPS_SINGLES, epsSinglesSortMode);
localStorage.setItem(STORAGE_SORT_KEYS.LIVE_ALBUMS, liveAlbumsSortMode);
localStorage.setItem(STORAGE_SORT_KEYS.COMPILATIONS, compilationsSortMode);
localStorage.setItem(STORAGE_SORT_KEYS.TRIBUTES, tributesSortMode);
localStorage.setItem(STORAGE_SORT_KEYS.OTHERS, othersSortMode);
} catch {
// localStorage not available
}
});
// Close sort menu when clicking outside
$effect(() => {
if (!showAlbumSortMenu) return;
function handleClick() {
showAlbumSortMenu = false;
}
// Delay to avoid closing immediately
setTimeout(() => {
document.addEventListener('click', handleClick);
}, 0);
return () => {
document.removeEventListener('click', handleClick);
};
});
async function loadArtistAlbumDownloadStatuses() {
const allAlbums = [
...artist.albums,
...artist.epsSingles,
...artist.liveAlbums,
...artist.compilations,
...artist.tributes,
...artist.others
];
await loadAllAlbumDownloadStatuses(allAlbums);
}
async function checkFavoriteStatus() {
try {
const response = await invoke<{ artists?: { items: Array<{ id: number }> } }>('get_favorites', {
favType: 'artists',
limit: 500,
offset: 0
});
if (response.artists?.items) {
isFavorite = response.artists.items.some(item => item.id === artist.id);
}
} catch (err) {
console.error('Failed to check artist favorite status:', err);
}
}
async function toggleFavorite() {
if (isFavoriteLoading) return;
isFavoriteLoading = true;
const wasFavorite = isFavorite;
try {
if (wasFavorite) {
await invoke('remove_favorite', { favType: 'artist', itemId: String(artist.id) });
isFavorite = false;
} else {
await invoke('add_favorite', { favType: 'artist', itemId: String(artist.id) });
isFavorite = true;
}
} catch (err) {
console.error('Failed to toggle artist favorite:', err);
isFavorite = wasFavorite; // Rollback on error
} finally {
isFavoriteLoading = false;
}
}
async function toggleBlacklist() {
if (isBlacklistLoading) return;
isBlacklistLoading = true;
const wasBlacklisted = artistIsBlacklisted;
try {
if (wasBlacklisted) {
await removeFromBlacklist(artist.id);
artistIsBlacklisted = false;
showToast(`${artist.name} removed from blacklist`, 'success');
} else {
await addToBlacklist(artist.id, artist.name);
artistIsBlacklisted = true;
showToast(`${artist.name} added to blacklist`, 'success');
}
} catch (err) {
console.error('Failed to toggle artist blacklist:', err);
artistIsBlacklisted = wasBlacklisted; // Rollback on error
showToast('Failed to update blacklist', 'error');
} finally {
isBlacklistLoading = false;
}
}
async function createArtistRadio() {
if (isRadioLoading) return;
isRadioLoading = true;
radioJustCreated = false;
try {
// Show loading messages
radioLoadingMessage = 'Preparing the artist radio...';
await new Promise(resolve => setTimeout(resolve, 800));
radioLoadingMessage = 'Fetching similar artists';
const sessionId = await invoke<string>('create_artist_radio', {
artistId: artist.id,
artistName: artist.name
});
console.log(`[Radio] Artist radio created: ${sessionId}`);
radioLoadingMessage = 'Radio function is still experimental...';
await new Promise(resolve => setTimeout(resolve, 400));
// Sync context from backend
await getPlaybackContext();
// Play first track from queue
const firstTrack = await playQueueIndex(0);
if (firstTrack && onTrackPlay) {
console.log(`[Radio] First track:`, firstTrack);
// Start playback using the onTrackPlay callback
onTrackPlay({
id: firstTrack.id,
title: firstTrack.title,
artist: firstTrack.artist,
album: firstTrack.album,
albumArt: firstTrack.artwork_url || '',
duration: formatDuration(firstTrack.duration_secs),
durationSeconds: firstTrack.duration_secs,
hires: firstTrack.hires,
bitDepth: firstTrack.bit_depth ?? undefined,
samplingRate: firstTrack.sample_rate ?? undefined,
albumId: firstTrack.album_id ?? undefined,
artistId: firstTrack.artist_id ?? undefined,
});
console.log(`[Radio] Started playback of track ${firstTrack.id}`);
// Mark as just created for visual feedback
radioJustCreated = true;
setTimeout(() => { radioJustCreated = false; }, 3000);
} else {
console.log(`[Radio] Cannot start playback - firstTrack: ${!!firstTrack}, onTrackPlay: ${!!onTrackPlay}`);
}
} catch (err) {
console.error('Failed to create artist radio:', err);
// TODO: Show user-facing error toast if available
} finally {
isRadioLoading = false;
radioLoadingMessage = '';
}
}
async function createTrackRadio(track: Track) {
try {
const trackName = track.title;
const trackArtistId = track.performer?.id || artist.id;
const sessionId = await invoke<string>('create_track_radio', {
trackId: track.id,
trackName,
artistId: trackArtistId
});
console.log(`[Radio] Track radio created: ${sessionId}`);
// Sync context from backend
await getPlaybackContext();
// Play first track from queue
const firstTrack = await playQueueIndex(0);
if (firstTrack && onTrackPlay) {
console.log(`[Radio] First track:`, firstTrack);
// Start playback using the onTrackPlay callback
onTrackPlay({
id: firstTrack.id,
title: firstTrack.title,
artist: firstTrack.artist,
album: firstTrack.album,
albumArt: firstTrack.artwork_url || '',
duration: formatDuration(firstTrack.duration_secs),
durationSeconds: firstTrack.duration_secs,
hires: firstTrack.hires,
bitDepth: firstTrack.bit_depth ?? undefined,
samplingRate: firstTrack.sample_rate ?? undefined,
albumId: firstTrack.album_id ?? undefined,
artistId: firstTrack.artist_id ?? undefined,
});
console.log(`[Radio] Started playback of track ${firstTrack.id}`);
}
} catch (err) {
console.error('Failed to create track radio:', err);
// TODO: Show user-facing error toast if available
}
}
async function loadTopTracks() {
tracksLoading = true;
try {
// Search for tracks by artist name
const results = await invoke<SearchResults>('search_tracks', {
query: artist.name,
limit: 30,
offset: 0
});
// Filter to only include tracks by this artist
topTracks = results.items.filter(track =>
track.performer?.name?.toLowerCase() === artist.name.toLowerCase()
).slice(0, 20);
} catch (err) {
console.error('Failed to load top tracks:', err);
} finally {
tracksLoading = false;
}
}
async function loadSimilarArtists() {
similarArtistsLoading = true;
try {
const results = await invoke<SimilarArtistsPage>('get_similar_artists', {
artistId: artist.id,
limit: 5,
offset: 0
});
similarArtists = results.items
.filter(item => item.id !== artist.id)
.slice(0, 5);
} catch (err) {
console.error('Failed to load similar artists:', err);
similarArtists = [];
} finally {
similarArtistsLoading = false;
}
}
// Load MusicBrainz relationships for artist enrichment
async function loadMusicBrainzRelationships() {
// First, resolve the artist to get MBID
mbRelationshipsLoading = true;
mbRelationships = null;
mbArtistMbid = null;
mbAvailable = true; // Reset on each load attempt
try {
// Check if MusicBrainz is enabled
const enabled = await invoke<boolean>('musicbrainz_is_enabled');
if (!enabled) {
mbAvailable = false;
mbRelationshipsLoading = false;
return;
}
// Resolve artist name to MBID
const resolved = await invoke<{
mbid?: string;
name?: string;
confidence: string;
}>('musicbrainz_resolve_artist', { name: artist.name });
if (!resolved?.mbid || resolved.confidence === 'none') {
mbRelationshipsLoading = false;
return;
}
mbArtistMbid = resolved.mbid;
// Fetch relationships
const relationships = await invoke<{
members: RelatedArtist[];
past_members: RelatedArtist[];
groups: RelatedArtist[];
collaborators: RelatedArtist[];
}>('musicbrainz_get_artist_relationships', { mbid: resolved.mbid });
mbRelationships = {
members: relationships.members || [],
pastMembers: relationships.past_members || [],
groups: relationships.groups || [],
collaborators: relationships.collaborators || []
};
} catch (err) {
console.error('Failed to load MusicBrainz relationships:', err);
mbAvailable = false;
mbRelationships = null;
} finally {
mbRelationshipsLoading = false;
}
}
// Navigate to a related artist by searching Qobuz
async function navigateToRelatedArtist(name: string) {
try {
// Search for the artist on Qobuz
const results = await invoke<{ artists?: { items: QobuzArtist[] } }>('search_artists', {
query: name,
limit: 5
});
if (results?.artists?.items?.length) {
// Find the best match (exact name match or first result)
const exactMatch = results.artists.items.find(
a => a.name.toLowerCase() === name.toLowerCase()
);
const artistToNavigate = exactMatch || results.artists.items[0];
// Use the existing navigation callback
if (onTrackGoToArtist && artistToNavigate.id) {
onTrackGoToArtist(artistToNavigate.id);
}
}
} catch (err) {
console.error('Failed to navigate to related artist:', err);
}
}
// Check if we have any relationships to show
let hasRelationships = $derived(
mbRelationships &&
(mbRelationships.members.length > 0 ||
mbRelationships.groups.length > 0 ||
mbRelationships.collaborators.length > 0)
);
// Group members by MBID, combining their roles
function groupMembersByMbid(members: RelatedArtist[]): GroupedMember[] {
const grouped = new Map<string, GroupedMember>();
for (const member of members) {
const existing = grouped.get(member.mbid);
if (existing) {
if (member.role && !existing.roles.includes(member.role)) {
existing.roles.push(member.role);
}
// If any entry is ended, mark as ended
if (member.ended) {
existing.ended = true;
}
} else {
grouped.set(member.mbid, {
mbid: member.mbid,
name: member.name,
roles: member.role ? [member.role] : [],
period: member.period,
ended: member.ended
});
}
}
return Array.from(grouped.values());
}
let groupedMembers = $derived(
mbRelationships ? groupMembersByMbid(mbRelationships.members) : []
);
let groupedGroups = $derived(
mbRelationships ? groupMembersByMbid(mbRelationships.groups) : []
);
let groupedCollaborators = $derived(
mbRelationships ? groupMembersByMbid(mbRelationships.collaborators) : []
);
function getSimilarArtistImage(similar: QobuzArtist): string {
return (
similar.image?.small ||
similar.image?.thumbnail ||
similar.image?.large ||
''
);
}
function handleSimilarArtistImageError(artistId: number) {
similarArtistImageErrors = new Set([...similarArtistImageErrors, artistId]);
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function handlePausePlayback(event: MouseEvent) {
event.stopPropagation();
void togglePlay();
}
function buildTopTracksQueue(tracks: Track[]) {
return tracks.map(t => ({
id: t.id,
title: t.title,
artist: t.performer?.name || artist.name,
album: t.album?.title || '',
duration_secs: t.duration,
artwork_url: t.album?.image?.large || t.album?.image?.thumbnail || '',
hires: t.hires_streamable ?? false,
bit_depth: t.maximum_bit_depth ?? null,
sample_rate: t.maximum_sampling_rate ?? null,
is_local: false,
album_id: t.album?.id || null,
artist_id: t.performer?.id ?? null,
}));
}
async function handleTrackPlay(track: Track, trackIndex?: number) {
// Create artist top tracks context
if (topTracks.length > 0) {
const trackIds = topTracks.map(t => t.id);
const index = trackIndex !== undefined ? trackIndex : trackIds.indexOf(track.id);
if (index >= 0) {
await setPlaybackContext(
'artist_top',
artist.id.toString(),
artist.name,
'qobuz',
trackIds,
index
);
console.log(`[Artist] Context created: "${artist.name}" top tracks, ${trackIds.length} tracks, starting at ${index}`);
try {
const queueTracks = buildTopTracksQueue(topTracks);
await invoke('set_queue', { tracks: queueTracks, startIndex: index });
} catch (err) {
console.error('Failed to set queue:', err);
}
}
}
// Play track
if (onTrackPlay) {
onTrackPlay({
id: track.id,
title: track.title,
artist: track.performer?.name || artist.name,
album: track.album?.title || '',
albumArt: track.album?.image?.large || track.album?.image?.thumbnail || '',
duration: formatDuration(track.duration),
durationSeconds: track.duration,
hires: track.hires_streamable,
bitDepth: track.maximum_bit_depth,
samplingRate: track.maximum_sampling_rate,
albumId: track.album?.id,
artistId: track.performer?.id ?? artist.id,
isrc: track.isrc,
});
}
}
async function handlePlayAllTracks() {
if (topTracks.length === 0 || !onTrackPlay) return;
try {
await handleTrackPlay(topTracks[0], 0);
} catch (err) {
console.error('Failed to set queue:', err);
}
}
function handlePlayAllTracksNext() {
if (!onTrackPlayNext) return;
// Add all tracks to play next (in reverse order so first track plays first)
for (let i = topTracks.length - 1; i >= 0; i--) {
onTrackPlayNext(topTracks[i]);
}
}
function handlePlayAllTracksLater() {
if (!onTrackPlayLater) return;
// Add all tracks to play later
for (const track of topTracks) {
onTrackPlayLater(track);
}
}
async function handleShuffleAllTracks() {
if (topTracks.length === 0 || !onTrackPlay) return;
// Shuffle and play from random position
const randomIndex = Math.floor(Math.random() * topTracks.length);
try {
await handleTrackPlay(topTracks[randomIndex], randomIndex);
} catch (err) {
console.error('Failed to shuffle tracks:', err);
}
}
function handleAddAllTracksToPlaylist() {
if (!onTrackAddToPlaylist || topTracks.length === 0) return;
// Add first track to playlist (this opens the playlist picker)
// The UI typically handles adding multiple tracks through a different flow
onTrackAddToPlaylist(topTracks[0].id);
}
function handleImageError() {
imageError = true;
}
// Get biography text (prefer content for full text, fall back to summary)
let bioText = $derived(
artist.biography?.content || artist.biography?.summary || null
);
// Smart 3-line truncation with resize detection
let bioTextEl = $state<HTMLDivElement | null>(null);
let bioContainerEl = $state<HTMLDivElement | null>(null);
let isBioTruncated = $state(false);
function checkBioTruncation() {
if (!bioTextEl || bioExpanded) return;
// scrollHeight > clientHeight means content is overflowing (truncated by line-clamp)
isBioTruncated = bioTextEl.scrollHeight > bioTextEl.clientHeight + 1;
}
$effect(() => {
if (!bioTextEl || !bioText || !bioContainerEl) return;
// Initial check after a frame to ensure layout is complete
requestAnimationFrame(() => {
checkBioTruncation();
});
// Observe the PARENT container (not the clamped element) for width changes
const observer = new ResizeObserver(() => {
if (!bioExpanded) {
// Double RAF to ensure CSS has applied after resize
requestAnimationFrame(() => {
requestAnimationFrame(() => {
checkBioTruncation();
});
});
}
});
observer.observe(bioContainerEl);
return () => observer.disconnect();
});
// Recheck truncation after collapsing (wait for DOM update)
$effect(() => {
if (bioExpanded === false && bioTextEl) {
// Use double RAF to wait for CSS to fully apply
requestAnimationFrame(() => {
requestAnimationFrame(() => {
checkBioTruncation();
});
});
}
});
let hasTopTracks = $derived(topTracks.length > 0 || tracksLoading);
let hasEpsSingles = $derived(artist.epsSingles.length > 0);
let hasLiveAlbums = $derived(artist.liveAlbums.length > 0);
let hasCompilations = $derived(artist.compilations.length > 0);
let hasTributes = $derived(artist.tributes.length > 0);
let hasOthers = $derived(artist.others.length > 0);
let hasPlaylists = $derived(artist.playlists.length > 0);
let jumpSections = $derived.by(() => [
{ id: 'about', label: 'About', el: aboutSection, visible: true },
{ id: 'popular', label: 'Popular Tracks', el: topTracksSection, visible: hasTopTracks },
{ id: 'discography', label: 'Discography', el: discographySection, visible: true },
{ id: 'eps', label: 'EPs & Singles', el: epsSinglesSection, visible: hasEpsSingles },
{ id: 'live', label: 'Live Albums', el: liveAlbumsSection, visible: hasLiveAlbums },
{ id: 'compilations', label: 'Compilations', el: compilationsSection, visible: hasCompilations },
{ id: 'others', label: 'Others', el: othersSection, visible: hasOthers },
{ id: 'playlists', label: 'Playlists', el: playlistsSection, visible: hasPlaylists },
{ id: 'tributes', label: 'Tributes', el: tributesSection, visible: hasTributes },
].filter(section => section.visible));
let showJumpNav = $derived(jumpSections.length > 1);
// Album sorting helper
type AlbumItem = { id: string; title: string; year?: string; artwork: string; quality: string };
function sortAlbums<T extends AlbumItem>(albums: T[], mode: AlbumSortMode): T[] {
if (mode === 'default') return albums;
return [...albums].sort((a, b) => {
switch (mode) {
case 'newest': {
const yearA = a.year || '0000';
const yearB = b.year || '0000';
return yearB.localeCompare(yearA);
}
case 'oldest': {
const yearA = a.year || '9999';
const yearB = b.year || '9999';
return yearA.localeCompare(yearB);
}
case 'title-asc':
return a.title.localeCompare(b.title);
case 'title-desc':
return b.title.localeCompare(a.title);
default:
return 0;
}
});
}
// Search filtering and sorting
let searchLower = $derived(searchQuery.toLowerCase().trim());
let filteredAlbums = $derived.by(() => {
let albums = searchLower
? artist.albums.filter(a => a.title.toLowerCase().includes(searchLower))
: artist.albums;
return sortAlbums(albums, albumSortMode);
});
let filteredEpsSingles = $derived.by(() => {
let albums = searchLower
? artist.epsSingles.filter(a => a.title.toLowerCase().includes(searchLower))
: artist.epsSingles;
return sortAlbums(albums, epsSinglesSortMode);
});
let filteredLiveAlbums = $derived.by(() => {
let albums = searchLower
? artist.liveAlbums.filter(a => a.title.toLowerCase().includes(searchLower))
: artist.liveAlbums;
return sortAlbums(albums, liveAlbumsSortMode);
});
let filteredCompilations = $derived.by(() => {
let albums = searchLower
? artist.compilations.filter(a => a.title.toLowerCase().includes(searchLower))
: artist.compilations;
return sortAlbums(albums, compilationsSortMode);
});
let filteredTributes = $derived.by(() => {
let albums = searchLower
? artist.tributes.filter(a => a.title.toLowerCase().includes(searchLower))
: artist.tributes;
return sortAlbums(albums, tributesSortMode);
});
let visibleTributes = $derived(filteredTributes.slice(0, tributesVisibleCount));
let canLoadMoreTributes = $derived(tributesVisibleCount < filteredTributes.length);
let filteredOthers = $derived.by(() => {
let albums = searchLower
? artist.others.filter(a => a.title.toLowerCase().includes(searchLower))
: artist.others;
return sortAlbums(albums, othersSortMode);
});
let filteredPlaylists = $derived(
searchLower
? artist.playlists.filter(p => p.title.toLowerCase().includes(searchLower))
: artist.playlists
);
let totalFilteredResults = $derived(
filteredAlbums.length + filteredEpsSingles.length + filteredLiveAlbums.length +
filteredCompilations.length + filteredTributes.length + filteredOthers.length +
filteredPlaylists.length
);
// Collect all result IDs for navigation
let allSearchResultIds = $derived.by(() => {
if (!searchLower) return [];
const ids: string[] = [];
filteredAlbums.forEach(a => ids.push(`album-${a.id}`));
filteredEpsSingles.forEach(a => ids.push(`album-${a.id}`));
filteredLiveAlbums.forEach(a => ids.push(`album-${a.id}`));
filteredCompilations.forEach(a => ids.push(`album-${a.id}`));
filteredTributes.forEach(a => ids.push(`album-${a.id}`));
filteredOthers.forEach(a => ids.push(`album-${a.id}`));
filteredPlaylists.forEach(p => ids.push(`playlist-${p.id}`));
return ids;
});
// Reset index when search changes
$effect(() => {
if (searchQuery) {
currentSearchIndex = 0;
// Navigate to first result
if (allSearchResultIds.length > 0) {
setTimeout(() => navigateToResult(0), 100);
}
}
});
function toggleSearch() {
if (searchOpen) {
searchOpen = false;
searchQuery = '';
currentSearchIndex = 0;
} else {
searchOpen = true;
setTimeout(() => searchInputEl?.focus(), 100);
}
}
function clearSearch() {
searchQuery = '';
currentSearchIndex = 0;
}
function navigateToResult(index: number) {
if (allSearchResultIds.length === 0) return;
const id = allSearchResultIds[index];
if (!id) return;
// Find the AlbumCard element by data attribute
const element = artistDetailEl?.querySelector(`[data-search-id="${id}"]`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
function nextResult() {
if (allSearchResultIds.length === 0) return;
currentSearchIndex = (currentSearchIndex + 1) % allSearchResultIds.length;
navigateToResult(currentSearchIndex);
}
function prevResult() {
if (allSearchResultIds.length === 0) return;
currentSearchIndex = currentSearchIndex === 0
? allSearchResultIds.length - 1
: currentSearchIndex - 1;
navigateToResult(currentSearchIndex);
}
function scrollToSection(target: HTMLDivElement | null, id: string) {
activeJumpSection = id;
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
async function scrollToTrack(trackId: number) {
await tick();
const target = artistDetailEl?.querySelector<HTMLElement>(`[data-track-id="${trackId}"]`);
target?.scrollIntoView({ block: 'center' });
}
$effect(() => {
if (!artistDetailEl || topTracks.length === 0) return;
const targetId = consumeContextTrackFocus('artist_top', artist.id.toString());
if (targetId !== null) {
void scrollToTrack(targetId);
}
});
$effect(() => {
if (!artistDetailEl) return;
if (jumpObserver) {
jumpObserver.disconnect();
jumpObserver = null;
}
if (jumpSections.length === 0) return;
const sectionByElement = new Map<HTMLDivElement, string>();
for (const section of jumpSections) {
if (section.el) {
sectionByElement.set(section.el, section.id);
}
}
const targets = [...sectionByElement.keys()];
if (targets.length === 0) return;
jumpObserver = new IntersectionObserver(
(entries) => {
const visible = entries.filter(entry => entry.isIntersecting);
if (visible.length === 0) return;
visible.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
const targetId = sectionByElement.get(visible[0].target as HTMLDivElement);
if (targetId) {
activeJumpSection = targetId;
}
},
{
root: artistDetailEl,
rootMargin: '-20% 0px -60% 0px',
threshold: [0.5] // Single threshold for better performance
}
);
targets.forEach(target => jumpObserver?.observe(target));
return () => {
jumpObserver?.disconnect();
jumpObserver = null;
};
});
</script>
<div class="artist-detail" bind:this={artistDetailEl}>
<!-- Back Navigation -->
<button class="back-btn" onclick={onBack}>
<ArrowLeft size={16} />
<span>Back</span>
</button>
<!-- Artist Header -->
<div class="artist-header section-anchor" bind:this={aboutSection}>
<!-- Artist Image -->
<div class="artist-image-column">
<div class="artist-image-container">
{#if imageError || !artist.image}
<div class="artist-image-placeholder">
<User size={60} />
</div>
{:else}
<img
src={artist.image}
alt={artist.name}
class="artist-image"
loading="lazy"
decoding="async"
onerror={handleImageError}
/>
{/if}
</div>
</div>
<!-- Artist Info -->
<div class="artist-info">
<h1 class="artist-name">{artist.name}</h1>
<!-- Biography -->
{#if bioText}
<div class="biography" bind:this={bioContainerEl}>
<div class="bio-text" class:expanded={bioExpanded} bind:this={bioTextEl}>
{@html bioText}
</div>
{#if isBioTruncated || bioExpanded}
<button class="bio-toggle" onclick={() => bioExpanded = !bioExpanded}>
{#if bioExpanded}
<ChevronUp size={16} />
<span>Show less</span>
{:else}
<ChevronDown size={16} />
<span>Read more</span>
{/if}
</button>
{/if}
{#if artist.biography?.source}
<div class="bio-source">Source: {artist.biography.source}</div>
{/if}
</div>
{/if}
<!-- TEMPORARILY HIDDEN FOR SIDEBAR EXPERIMENTS
{#if similarArtistsLoading}
<div class="similar-loading">Loading similar artists...</div>
{:else if similarArtists.length > 0}
<div class="similar-artists">
<div class="similar-title">SIMILAR ARTISTS</div>
<div class="similar-list">
{#each similarArtists as similar, index}
{#if index > 0}
<span class="similar-separator">•</span>
{/if}
<button
class="similar-artist"
onclick={() => onTrackGoToArtist?.(similar.id)}
title={similar.name}
>
{similar.name}
</button>
{/each}
</div>
</div>
{/if}
MusicBrainz Relationships
{#if mbRelationshipsLoading}
<div class="mb-relationships-loading">Loading artist relationships...</div>
{:else if hasRelationships}
<div class="mb-relationships">
{#if mbRelationships && mbRelationships.members.length > 0}
<div class="mb-section">
<div class="mb-section-title">BAND MEMBERS</div>
<div class="mb-members-list">
{#each mbRelationships.members as member}
<button
class="mb-member"
onclick={() => navigateToRelatedArtist(member.name)}
title={member.role ? `${member.name} (${member.role})` : member.name}
>
<User size={14} class="mb-member-icon" />
<span class="mb-member-name">{member.name}</span>
{#if member.role}
<span class="mb-member-role">{member.role}</span>
{/if}
</button>
{/each}
</div>
</div>
{/if}
{#if mbRelationships && mbRelationships.pastMembers.length > 0}
<div class="mb-section">
<div class="mb-section-title">PAST MEMBERS</div>
<div class="mb-members-list">
{#each mbRelationships.pastMembers as member}
<button
class="mb-member past"
onclick={() => navigateToRelatedArtist(member.name)}
title={member.role ? `${member.name} (${member.role})` : member.name}
>
<User size={14} class="mb-member-icon" />
<span class="mb-member-name">{member.name}</span>
{#if member.role}
<span class="mb-member-role">{member.role}</span>
{/if}
</button>
{/each}
</div>
</div>
{/if}
{#if mbRelationships && mbRelationships.groups.length > 0}
<div class="mb-section">
<div class="mb-section-title">MEMBER OF</div>
<div class="mb-groups-list">
{#each mbRelationships.groups as group}
<button
class="mb-group"
onclick={() => navigateToRelatedArtist(group.name)}
title={group.name}
>
<Music size={14} class="mb-group-icon" />
<span class="mb-group-name">{group.name}</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
{/if}
END TEMPORARILY HIDDEN -->
<!-- Action Buttons -->
<div class="artist-actions">
<button
class="favorite-btn"
class:is-favorite={isFavorite}
onclick={toggleFavorite}
disabled={isFavoriteLoading}
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
>
{#if isFavorite}
<Heart size={24} fill="var(--accent-primary)" color="var(--accent-primary)" />
{:else}
<Heart size={24} />
{/if}
</button>
<div class="radio-btn-wrapper">
<button
class="radio-btn"
class:loading={isRadioLoading}
class:glow={radioJustCreated}
onclick={createArtistRadio}
disabled={isRadioLoading}
title="Start Artist Radio"
>
<Radio size={24} />
</button>
{#if isRadioLoading && radioLoadingMessage}
{#key radioLoadingMessage}
<span class="floating-message">{radioLoadingMessage}</span>
{/key}
{/if}
</div>
<button
class="network-btn"
class:active={showNetworkSidebar}
onclick={() => toggleContentSidebar('network')}
title="Artist Network"
>
<img src="/element-connect.svg" alt="Network" class="network-icon" />
</button>
<button
class="blacklist-btn"
class:is-blacklisted={artistIsBlacklisted}
onclick={toggleBlacklist}
disabled={isBlacklistLoading}
title={artistIsBlacklisted ? 'Remove from blacklist' : 'Add to blacklist'}
>
<Ban size={24} />
</button>
</div>
</div>
</div>
<!-- Blacklist Warning Banner -->
{#if artistIsBlacklisted}
<div class="blacklist-banner">
<Ban size={18} />
<span>This artist is blacklisted. Their music is hidden from search, radio, and suggestions.</span>
<button class="unblock-btn" onclick={toggleBlacklist} disabled={isBlacklistLoading}>
Unblock
</button>
</div>
{/if}
{#if showJumpNav}
<div class="jump-nav">
<div class="jump-nav-left">
<div class="jump-label">Jump to</div>
<div class="jump-links">
{#each jumpSections as section}
<button
class="jump-link"
class:active={activeJumpSection === section.id}
onclick={() => scrollToSection(section.el, section.id)}
>
{section.label}
</button>
{/each}
</div>
</div>
<div class="page-search" class:open={searchOpen}>
{#if searchOpen}
<div class="search-input-container">
<input
type="text"
class="search-input"
placeholder="Search in this page..."
bind:value={searchQuery}
bind:this={searchInputEl}
onkeydown={(e) => {
if (e.key === 'Escape') toggleSearch();
else if (e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) prevResult();
else nextResult();
}
}}
/>
<div class="search-controls">
{#if searchQuery}
<span class="search-count">
{#if totalFilteredResults === 0}
0/0
{:else}
{currentSearchIndex + 1}/{totalFilteredResults}
{/if}
</span>
<button
class="search-nav-btn"
onclick={prevResult}
disabled={totalFilteredResults === 0}
title="Previous result (Shift+Enter)"
>
<ChevronLeft size={16} />
</button>
<button
class="search-nav-btn"
onclick={nextResult}
disabled={totalFilteredResults === 0}
title="Next result (Enter)"
>
<ChevronRight size={16} />
</button>
{/if}
<button class="search-close-btn" onclick={toggleSearch} title="Close search">
<X size={16} />
</button>
</div>
</div>
{:else}
<button class="search-toggle" onclick={toggleSearch} title="Search in this page">
<Search size={18} />
</button>
{/if}
</div>
</div>
{/if}
<!-- Top Tracks Section -->
{#if topTracks.length > 0 || tracksLoading}
<div class="top-tracks-section section-anchor" bind:this={topTracksSection}>
<div class="section-header">
<div class="section-header-left">
<h2 class="section-title">Popular Tracks</h2>
</div>
{#if topTracks.length > 0}
<div class="section-header-actions">
<button class="action-btn-circle primary" onclick={handlePlayAllTracks} title="Play All">
<Play size={20} fill="currentColor" color="currentColor" />
</button>
<div class="context-menu-wrapper">
<button
class="action-btn-circle"
onclick={() => showTracksContextMenu = !showTracksContextMenu}
title="More options"
>
<MoreHorizontal size={18} />
</button>
{#if showTracksContextMenu}
<div class="context-menu-backdrop" onclick={() => showTracksContextMenu = false} role="presentation"></div>
<div class="context-menu">
<button class="context-menu-item" onclick={() => { handlePlayAllTracksNext(); showTracksContextMenu = false; }}>
Play Next
</button>
<button class="context-menu-item" onclick={() => { handlePlayAllTracksLater(); showTracksContextMenu = false; }}>
Add to queue
</button>
<button class="context-menu-item" onclick={() => { handleShuffleAllTracks(); showTracksContextMenu = false; }}>
Shuffle
</button>
<button class="context-menu-item" onclick={() => { handleAddAllTracksToPlaylist(); showTracksContextMenu = false; }}>
Add to Playlist
</button>
</div>
{/if}
</div>
</div>
{/if}
</div>
{#if tracksLoading}
<div class="tracks-loading">Loading tracks...</div>
{:else}
<div class="tracks-list">
{#each visibleTracks as track, index}
{@const isActiveTrack = isPlaybackActive && activeTrackId === track.id}
<div
class="track-row"
class:playing={isActiveTrack}
role="button"
tabindex="0"
data-track-id={track.id}
onclick={() => handleTrackPlay(track, index)}
onkeydown={(e) => e.key === 'Enter' && handleTrackPlay(track, index)}
>
<div class="track-number">{index + 1}</div>
<div class="track-artwork">
<!-- Placeholder always visible as background -->
<div class="track-artwork-placeholder">
<Music size={16} />
</div>
<!-- Image overlays placeholder when loaded -->
{#if track.album?.image?.thumbnail || track.album?.image?.small}
<img src={track.album?.image?.thumbnail || track.album?.image?.small} alt={track.title} loading="lazy" decoding="async" />
{/if}
<button
class="track-play-overlay"
class:is-playing={isActiveTrack}
onclick={(event) => {
if (isActiveTrack) {
handlePausePlayback(event);
} else {
event.stopPropagation();
handleTrackPlay(track, index);
}
}}
aria-label={isActiveTrack ? 'Pause track' : 'Play track'}
>
<span class="play-icon" aria-hidden="true">
<Play size={18} />
</span>
<div class="playing-indicator" aria-hidden="true">
<div class="bar"></div>
<div class="bar"></div>
<div class="bar"></div>
</div>
<span class="pause-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="white">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
</span>
</button>
</div>
<div class="track-info">
<div class="track-title">{track.title}</div>
{#if track.album?.id && onTrackGoToAlbum}
<button
class="track-album track-link"
type="button"
onclick={(event) => {
event.stopPropagation();
onTrackGoToAlbum?.(track.album!.id);
}}
>
{track.album?.title || ''}
</button>
{:else}
<div class="track-album">{track.album?.title || ''}</div>
{/if}
</div>
<div class="track-quality">
<QualityBadge
bitDepth={track.maximum_bit_depth}
samplingRate={track.maximum_sampling_rate}
compact
/>
</div>
<div class="track-duration">{formatDuration(track.duration)}</div>
<div class="track-actions">
{#if onTrackAddFavorite}
<button
class="track-favorite-btn"
onclick={(e) => {
e.stopPropagation();
onTrackAddFavorite(track.id);
}}
title="Add to favorites"
>
<Heart size={16} />
</button>
{/if}
<TrackMenu
onPlayNow={() => handleTrackPlay(track, index)}
onPlayNext={onTrackPlayNext ? () => onTrackPlayNext(track) : undefined}
onPlayLater={onTrackPlayLater ? () => onTrackPlayLater(track) : undefined}
onCreateRadio={() => createTrackRadio(track)}
onAddFavorite={onTrackAddFavorite ? () => onTrackAddFavorite(track.id) : undefined}
onAddToPlaylist={onTrackAddToPlaylist ? () => onTrackAddToPlaylist(track.id) : undefined}
onShareQobuz={onTrackShareQobuz ? () => onTrackShareQobuz(track.id) : undefined}
onShareSonglink={onTrackShareSonglink ? () => onTrackShareSonglink(track) : undefined}
onGoToAlbum={track.album?.id && onTrackGoToAlbum ? () => onTrackGoToAlbum(track.album!.id) : undefined}
/>
</div>
</div>
{/each}
</div>
{#if canLoadMoreTracks}
<button class="load-more-link" onclick={loadMoreTracks}>
Load More
</button>
{/if}
{/if}
</div>
{/if}
<!-- Discography Section -->
<div class="discography section-anchor" bind:this={discographySection}>
<div class="section-header">
<div class="section-header-left">
<h2 class="section-title">Discography</h2>
{#if artist.albums.length > 0}
<span class="section-count">{artist.albums.length}</span>
{/if}
</div>
<!-- Album Sort Dropdown -->
<div class="sort-dropdown">
<button class="sort-btn" onclick={() => (showAlbumSortMenu = !showAlbumSortMenu)}>
<span>
{#if albumSortMode === 'default'}Sort: Default
{:else if albumSortMode === 'newest'}Sort: Newest
{:else if albumSortMode === 'oldest'}Sort: Oldest
{:else if albumSortMode === 'title-asc'}Sort: A-Z
{:else if albumSortMode === 'title-desc'}Sort: Z-A
{/if}
</span>
<ChevronDown size={14} />
</button>
{#if showAlbumSortMenu}
<div class="sort-menu">
<button
class="sort-item"
class:selected={albumSortMode === 'default'}
onclick={() => { albumSortMode = 'default'; showAlbumSortMenu = false; }}
>
Default
</button>
<button
class="sort-item"
class:selected={albumSortMode === 'newest'}
onclick={() => { albumSortMode = 'newest'; showAlbumSortMenu = false; }}
>
Newest First
</button>
<button
class="sort-item"
class:selected={albumSortMode === 'oldest'}
onclick={() => { albumSortMode = 'oldest'; showAlbumSortMenu = false; }}
>
Oldest First
</button>
<button
class="sort-item"
class:selected={albumSortMode === 'title-asc'}
onclick={() => { albumSortMode = 'title-asc'; showAlbumSortMenu = false; }}
>
Title (A-Z)
</button>
<button
class="sort-item"
class:selected={albumSortMode === 'title-desc'}
onclick={() => { albumSortMode = 'title-desc'; showAlbumSortMenu = false; }}
>
Title (Z-A)
</button>
</div>
{/if}
</div>
</div>
{#if artist.albums.length === 0}
<div class="no-albums">No albums found</div>
{:else}
<div class="albums-grid">
{#each filteredAlbums as album}
<AlbumCard
albumId={album.id}
artwork={album.artwork}
title={album.title}
artist={album.year || ''}
genre={album.genre}
releaseDate={album.releaseDate}
size="large"
quality={album.quality}
searchId={`album-${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}
onclick={() => { onAlbumClick?.(album.id); loadAlbumDownloadStatus(album.id); }}
/>
{/each}
</div>
{/if}
</div>
{#if artist.epsSingles.length > 0}
<div class="divider"></div>
<div class="discography section-anchor" bind:this={epsSinglesSection}>
<div class="section-header">
<div class="section-header-left">
<h2 class="section-title">EPs & Singles</h2>
<span class="section-count">{artist.epsSingles.length}</span>
</div>
<div class="sort-dropdown">
<button class="sort-btn" onclick={() => (showEpsSinglesSortMenu = !showEpsSinglesSortMenu)}>
<span>
{#if epsSinglesSortMode === 'default'}Sort: Default
{:else if epsSinglesSortMode === 'newest'}Sort: Newest
{:else if epsSinglesSortMode === 'oldest'}Sort: Oldest
{:else if epsSinglesSortMode === 'title-asc'}Sort: A-Z
{:else if epsSinglesSortMode === 'title-desc'}Sort: Z-A
{/if}
</span>
<ChevronDown size={14} />
</button>
{#if showEpsSinglesSortMenu}
<div class="sort-menu">
<button class="sort-item" class:selected={epsSinglesSortMode === 'default'} onclick={() => { epsSinglesSortMode = 'default'; showEpsSinglesSortMenu = false; }}>Default</button>
<button class="sort-item" class:selected={epsSinglesSortMode === 'newest'} onclick={() => { epsSinglesSortMode = 'newest'; showEpsSinglesSortMenu = false; }}>Newest First</button>
<button class="sort-item" class:selected={epsSinglesSortMode === 'oldest'} onclick={() => { epsSinglesSortMode = 'oldest'; showEpsSinglesSortMenu = false; }}>Oldest First</button>
<button class="sort-item" class:selected={epsSinglesSortMode === 'title-asc'} onclick={() => { epsSinglesSortMode = 'title-asc'; showEpsSinglesSortMenu = false; }}>Title (A-Z)</button>
<button class="sort-item" class:selected={epsSinglesSortMode === 'title-desc'} onclick={() => { epsSinglesSortMode = 'title-desc'; showEpsSinglesSortMenu = false; }}>Title (Z-A)</button>
</div>
{/if}
</div>
</div>
<div class="albums-grid">
{#each filteredEpsSingles as album}
<AlbumCard
albumId={album.id}
artwork={album.artwork}
title={album.title}
artist={album.year || ''}
genre={album.genre}
releaseDate={album.releaseDate}
size="large"
quality={album.quality}
searchId={`album-${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}
onclick={() => { onAlbumClick?.(album.id); loadAlbumDownloadStatus(album.id); }}
/>
{/each}
</div>
</div>
{/if}
{#if artist.liveAlbums.length > 0}
<div class="divider"></div>
<div class="discography section-anchor" bind:this={liveAlbumsSection}>
<div class="section-header">
<div class="section-header-left">
<h2 class="section-title">Live Albums</h2>
<span class="section-count">{artist.liveAlbums.length}</span>
</div>
<div class="sort-dropdown">
<button class="sort-btn" onclick={() => (showLiveAlbumsSortMenu = !showLiveAlbumsSortMenu)}>
<span>
{#if liveAlbumsSortMode === 'default'}Sort: Default
{:else if liveAlbumsSortMode === 'newest'}Sort: Newest
{:else if liveAlbumsSortMode === 'oldest'}Sort: Oldest
{:else if liveAlbumsSortMode === 'title-asc'}Sort: A-Z
{:else if liveAlbumsSortMode === 'title-desc'}Sort: Z-A
{/if}
</span>
<ChevronDown size={14} />
</button>
{#if showLiveAlbumsSortMenu}
<div class="sort-menu">
<button class="sort-item" class:selected={liveAlbumsSortMode === 'default'} onclick={() => { liveAlbumsSortMode = 'default'; showLiveAlbumsSortMenu = false; }}>Default</button>
<button class="sort-item" class:selected={liveAlbumsSortMode === 'newest'} onclick={() => { liveAlbumsSortMode = 'newest'; showLiveAlbumsSortMenu = false; }}>Newest First</button>
<button class="sort-item" class:selected={liveAlbumsSortMode === 'oldest'} onclick={() => { liveAlbumsSortMode = 'oldest'; showLiveAlbumsSortMenu = false; }}>Oldest First</button>
<button class="sort-item" class:selected={liveAlbumsSortMode === 'title-asc'} onclick={() => { liveAlbumsSortMode = 'title-asc'; showLiveAlbumsSortMenu = false; }}>Title (A-Z)</button>
<button class="sort-item" class:selected={liveAlbumsSortMode === 'title-desc'} onclick={() => { liveAlbumsSortMode = 'title-desc'; showLiveAlbumsSortMenu = false; }}>Title (Z-A)</button>
</div>
{/if}
</div>
</div>
<div class="albums-grid">
{#each filteredLiveAlbums as album}
<AlbumCard
albumId={album.id}
artwork={album.artwork}
title={album.title}
artist={album.year || ''}
genre={album.genre}
releaseDate={album.releaseDate}
size="large"
quality={album.quality}
searchId={`album-${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}
onclick={() => { onAlbumClick?.(album.id); loadAlbumDownloadStatus(album.id); }}
/>
{/each}
</div>
</div>
{/if}
{#if artist.compilations.length > 0}
<div class="divider"></div>
<div class="discography section-anchor" bind:this={compilationsSection}>
<div class="section-header">
<div class="section-header-left">
<h2 class="section-title">Compilations</h2>
<span class="section-count">{artist.compilations.length}</span>
</div>
<div class="sort-dropdown">
<button class="sort-btn" onclick={() => (showCompilationsSortMenu = !showCompilationsSortMenu)}>
<span>
{#if compilationsSortMode === 'default'}Sort: Default
{:else if compilationsSortMode === 'newest'}Sort: Newest
{:else if compilationsSortMode === 'oldest'}Sort: Oldest
{:else if compilationsSortMode === 'title-asc'}Sort: A-Z
{:else if compilationsSortMode === 'title-desc'}Sort: Z-A
{/if}
</span>
<ChevronDown size={14} />
</button>
{#if showCompilationsSortMenu}
<div class="sort-menu">
<button class="sort-item" class:selected={compilationsSortMode === 'default'} onclick={() => { compilationsSortMode = 'default'; showCompilationsSortMenu = false; }}>Default</button>
<button class="sort-item" class:selected={compilationsSortMode === 'newest'} onclick={() => { compilationsSortMode = 'newest'; showCompilationsSortMenu = false; }}>Newest First</button>
<button class="sort-item" class:selected={compilationsSortMode === 'oldest'} onclick={() => { compilationsSortMode = 'oldest'; showCompilationsSortMenu = false; }}>Oldest First</button>
<button class="sort-item" class:selected={compilationsSortMode === 'title-asc'} onclick={() => { compilationsSortMode = 'title-asc'; showCompilationsSortMenu = false; }}>Title (A-Z)</button>
<button class="sort-item" class:selected={compilationsSortMode === 'title-desc'} onclick={() => { compilationsSortMode = 'title-desc'; showCompilationsSortMenu = false; }}>Title (Z-A)</button>
</div>
{/if}
</div>
</div>
<div class="albums-grid">
{#each filteredCompilations as album}
<AlbumCard
albumId={album.id}
artwork={album.artwork}
title={album.title}
artist={album.year || ''}
genre={album.genre}
releaseDate={album.releaseDate}
size="large"
quality={album.quality}
searchId={`album-${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}
onclick={() => { onAlbumClick?.(album.id); loadAlbumDownloadStatus(album.id); }}
/>
{/each}
</div>
</div>
{/if}
{#if artist.others.length > 0}
<div class="divider"></div>
<div class="discography section-anchor" bind:this={othersSection}>
<div class="section-header">
<div class="section-header-left">
<h2 class="section-title">Others</h2>
<span class="section-count">{artist.others.length}</span>
</div>
<div class="sort-dropdown">
<button class="sort-btn" onclick={() => (showOthersSortMenu = !showOthersSortMenu)}>
<span>
{#if othersSortMode === 'default'}Sort: Default
{:else if othersSortMode === 'newest'}Sort: Newest
{:else if othersSortMode === 'oldest'}Sort: Oldest
{:else if othersSortMode === 'title-asc'}Sort: A-Z
{:else if othersSortMode === 'title-desc'}Sort: Z-A
{/if}
</span>
<ChevronDown size={14} />
</button>
{#if showOthersSortMenu}
<div class="sort-menu">
<button class="sort-item" class:selected={othersSortMode === 'default'} onclick={() => { othersSortMode = 'default'; showOthersSortMenu = false; }}>Default</button>
<button class="sort-item" class:selected={othersSortMode === 'newest'} onclick={() => { othersSortMode = 'newest'; showOthersSortMenu = false; }}>Newest First</button>
<button class="sort-item" class:selected={othersSortMode === 'oldest'} onclick={() => { othersSortMode = 'oldest'; showOthersSortMenu = false; }}>Oldest First</button>
<button class="sort-item" class:selected={othersSortMode === 'title-asc'} onclick={() => { othersSortMode = 'title-asc'; showOthersSortMenu = false; }}>Title (A-Z)</button>
<button class="sort-item" class:selected={othersSortMode === 'title-desc'} onclick={() => { othersSortMode = 'title-desc'; showOthersSortMenu = false; }}>Title (Z-A)</button>
</div>
{/if}
</div>
</div>
<div class="albums-grid">
{#each filteredOthers as album}
<AlbumCard
albumId={album.id}
artwork={album.artwork}
title={album.title}
artist={album.year || ''}
genre={album.genre}
releaseDate={album.releaseDate}
size="large"
quality={album.quality}
searchId={`album-${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}
onclick={() => { onAlbumClick?.(album.id); loadAlbumDownloadStatus(album.id); }}
/>
{/each}
</div>
</div>
{/if}
{#if artist.playlists.length > 0}
<div class="divider"></div>
<div class="playlists-section section-anchor" bind:this={playlistsSection}>
<div class="section-header">
<div class="section-header-left">
<h2 class="section-title">Playlists</h2>
<span class="section-count">{artist.playlists.length}</span>
</div>
</div>
<div class="playlists-grid">
{#each filteredPlaylists as playlist}
<button
class="playlist-card"
data-search-id={`playlist-${playlist.id}`}
onclick={() => onPlaylistClick?.(playlist.id)}
disabled={!onPlaylistClick}
>
<div class="playlist-artwork">
<!-- Placeholder always visible as background -->
<div class="playlist-artwork-placeholder">
<Music size={18} />
</div>
<!-- Image overlays placeholder when loaded -->
{#if playlist.artwork}
<img src={playlist.artwork} alt={playlist.title} loading="lazy" decoding="async" />
{/if}
</div>
<div class="playlist-info">
<div class="playlist-title">{playlist.title}</div>
<div class="playlist-meta">
{#if playlist.trackCount}
{playlist.trackCount} tracks
{:else}
Playlist
{/if}
{#if playlist.owner}
· {playlist.owner}
{/if}
</div>
</div>
</button>
{/each}
</div>
</div>
{/if}
{#if artist.tributes.length > 0}
<div class="divider"></div>
<div class="discography section-anchor" bind:this={tributesSection}>
<div class="section-header">
<div class="section-header-left">
<h2 class="section-title">Tributes & Covers</h2>
<span class="section-count">{artist.tributes.length}</span>
<button
class="info-btn"
title="This section contains albums returned by Qobuz that may include covers and tributes by other artists, not necessarily music performed by the artist shown."
>
<Info size={14} />
</button>
<button class="collapse-btn" onclick={() => (tributesExpanded = !tributesExpanded)} title={tributesExpanded ? 'Collapse' : 'Expand'}>
{#if tributesExpanded}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
</button>
</div>
{#if tributesExpanded}
<div class="sort-dropdown">
<button class="sort-btn" onclick={() => (showTributesSortMenu = !showTributesSortMenu)}>
<span>
{#if tributesSortMode === 'default'}Sort: Default
{:else if tributesSortMode === 'newest'}Sort: Newest
{:else if tributesSortMode === 'oldest'}Sort: Oldest
{:else if tributesSortMode === 'title-asc'}Sort: A-Z
{:else if tributesSortMode === 'title-desc'}Sort: Z-A
{/if}
</span>
<ChevronDown size={14} />
</button>
{#if showTributesSortMenu}
<div class="sort-menu">
<button class="sort-item" class:selected={tributesSortMode === 'default'} onclick={() => { tributesSortMode = 'default'; showTributesSortMenu = false; }}>Default</button>
<button class="sort-item" class:selected={tributesSortMode === 'newest'} onclick={() => { tributesSortMode = 'newest'; showTributesSortMenu = false; }}>Newest First</button>
<button class="sort-item" class:selected={tributesSortMode === 'oldest'} onclick={() => { tributesSortMode = 'oldest'; showTributesSortMenu = false; }}>Oldest First</button>
<button class="sort-item" class:selected={tributesSortMode === 'title-asc'} onclick={() => { tributesSortMode = 'title-asc'; showTributesSortMenu = false; }}>Title (A-Z)</button>
<button class="sort-item" class:selected={tributesSortMode === 'title-desc'} onclick={() => { tributesSortMode = 'title-desc'; showTributesSortMenu = false; }}>Title (Z-A)</button>
</div>
{/if}
</div>
{/if}
</div>
{#if tributesExpanded}
<div class="albums-grid">
{#each visibleTributes as album}
<AlbumCard
albumId={album.id}
artwork={album.artwork}
title={album.title}
artist={album.year || ''}
genre={album.genre}
releaseDate={album.releaseDate}
size="large"
quality={album.quality}
searchId={`album-${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}
onclick={() => { onAlbumClick?.(album.id); loadAlbumDownloadStatus(album.id); }}
/>
{/each}
</div>
{#if canLoadMoreTributes}
<div class="load-more-container">
<button class="load-more-btn" onclick={() => (tributesVisibleCount += 20)}>
Load More ({filteredTributes.length - tributesVisibleCount} remaining)
</button>
</div>
{/if}
{/if}
</div>
{/if}
<!-- Artist Network Sidebar -->
{#if showNetworkSidebar}
<aside class="network-sidebar">
<div class="sidebar-header">
<div class="sidebar-header-icon">
<img src="/element-connect.svg" alt="" />
</div>
<div class="sidebar-header-text">
<h3 class="sidebar-title">Network</h3>
<div class="sidebar-subtitle">{artist.name}</div>
</div>
<button class="sidebar-close" onclick={() => toggleContentSidebar('network')} title="Close">
<X size={18} />
</button>
</div>
<div class="sidebar-content">
<!-- Labels Section -->
<section class="sidebar-section">
<h4 class="section-label">LABELS</h4>
<div class="section-items">
{#if artist.labels.length > 0}
{#each artist.labels as label}
<button
class="sidebar-artist-link"
onclick={() => onLabelClick?.(label.id, label.name)}
title={label.name}
>
<Disc size={12} />
{label.name}
</button>
{/each}
{:else}
<span class="placeholder-text">No label info</span>
{/if}
</div>
</section>
<!-- Similar Artists Section -->
<section class="sidebar-section">
<h4 class="section-label">SIMILAR ARTISTS</h4>
<div class="section-items">
{#if similarArtistsLoading}
<span class="placeholder-text">Loading...</span>
{:else if similarArtists.length > 0}
{#each similarArtists as similar}
<button
class="sidebar-artist-link"
onclick={() => onTrackGoToArtist?.(similar.id)}
title={similar.name}
>
<User size={12} />
{similar.name}
</button>
{/each}
{:else}
<span class="placeholder-text">No similar artists found</span>
{/if}
</div>
</section>
<!-- MusicBrainz Relationships (only shown if MB is enabled and available) -->
{#if mbAvailable}
<section class="sidebar-section">
<h4 class="section-label">RELATIONSHIPS</h4>
<div class="section-items">
{#if mbRelationshipsLoading}
<span class="placeholder-text">Loading...</span>
{:else if hasRelationships}
{#if groupedMembers.length > 0}
<div class="relationship-group">
<span class="relationship-label">Members & Former</span>
{#each groupedMembers as member}
{@const periodStr = member.period?.begin || member.period?.end
? `${member.period.begin || '?'} - ${member.period.end || 'present'}`
: ''}
{@const tooltipParts = [...member.roles]}
{@const tooltip = tooltipParts.length > 0
? (periodStr ? `${tooltipParts.join(', ')} (${periodStr})` : tooltipParts.join(', '))
: (periodStr || member.name)}
{@const memberRole = member.roles[0] || 'Band Member'}
<button
class="sidebar-artist-link"
onclick={() => onMusicianClick ? onMusicianClick(member.name, memberRole) : navigateToRelatedArtist(member.name)}
title={tooltip}
>
<User size={12} />
{member.name}
</button>
{/each}
</div>
{/if}
{#if groupedGroups.length > 0}
<div class="relationship-group">
<span class="relationship-label">Member Of</span>
{#each groupedGroups as group}
<button
class="sidebar-artist-link"
onclick={() => onMusicianClick ? onMusicianClick(group.name, 'Band') : navigateToRelatedArtist(group.name)}
title={group.roles.length > 0 ? group.roles.join(', ') : group.name}
>
<Music size={12} />
{group.name}
</button>
{/each}
</div>
{/if}
{#if groupedCollaborators.length > 0}
<div class="relationship-group">
<span class="relationship-label">Collaborators</span>
{#each groupedCollaborators as collab}
{@const collabRole = collab.roles[0] || 'Collaborator'}
<button
class="sidebar-artist-link"
onclick={() => onMusicianClick ? onMusicianClick(collab.name, collabRole) : navigateToRelatedArtist(collab.name)}
title={collab.roles.length > 0 ? collab.roles.join(', ') : collab.name}
>
<User size={12} />
{collab.name}
</button>
{/each}
</div>
{/if}
{:else}
<span class="placeholder-text">No relationships found</span>
{/if}
</div>
</section>
{/if}
</div>
</aside>
{/if}
</div>
<style>
.artist-detail {
width: 100%;
height: 100%;
padding: 24px;
padding-top: 0;
padding-left: 18px;
padding-right: 8px;
padding-bottom: 100px;
overflow-y: auto;
position: relative;
}
/* Network Sidebar - matches LyricsSidebar dimensions */
.network-sidebar {
position: fixed;
top: 32px;
bottom: 104px;
right: 0;
width: 340px;
background: var(--bg-secondary);
border-left: 1px solid var(--bg-tertiary);
z-index: 100;
display: flex;
flex-direction: column;
animation: slideIn 200ms ease-out;
}
/* Adjust sidebar when title bar is hidden */
:global(.app.no-titlebar) .network-sidebar {
top: 0;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.sidebar-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-bottom: 1px solid var(--bg-tertiary);
background: var(--bg-primary);
flex-shrink: 0;
}
.sidebar-header-icon {
width: 36px;
height: 36px;
display: grid;
place-items: center;
background: var(--bg-tertiary);
border-radius: 8px;
color: var(--accent-primary);
}
.sidebar-header-icon img {
width: 18px;
height: 18px;
filter: brightness(0) saturate(100%) invert(56%) sepia(63%) saturate(4848%) hue-rotate(230deg) brightness(102%) contrast(101%);
}
.sidebar-header-text {
flex: 1;
min-width: 0;
}
.sidebar-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
margin: 0;
}
.sidebar-subtitle {
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.sidebar-close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
color: var(--text-muted);
transition: background 150ms ease, color 150ms ease;
}
.sidebar-close:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.sidebar-section {
margin-bottom: 24px;
}
.sidebar-section:last-child {
margin-bottom: 0;
}
.section-label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.5px;
margin: 0 0 12px 0;
}
.section-items {
display: flex;
flex-direction: column;
gap: 2px;
}
.placeholder-text {
font-size: 13px;
color: var(--text-muted);
font-style: italic;
}
.sidebar-artist-link {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
color: var(--text-secondary);
font-size: 12px;
text-align: left;
transition: background 150ms ease, color 150ms ease;
}
.sidebar-artist-link:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.relationship-group {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 16px;
}
.relationship-group:last-child {
margin-bottom: 0;
}
.relationship-label {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
margin-bottom: 6px;
}
/* Custom scrollbar */
.artist-detail::-webkit-scrollbar {
width: 6px;
}
.artist-detail::-webkit-scrollbar-track {
background: transparent;
}
.artist-detail::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 3px;
}
.artist-detail::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.back-btn {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text-muted);
background: none;
border: none;
cursor: pointer;
margin-top: 24px;
margin-bottom: 24px;
transition: color 150ms ease;
}
.back-btn:hover {
color: var(--text-secondary);
}
.artist-header {
display: flex;
gap: 32px;
margin-bottom: 22px;
}
.artist-image-column {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.artist-image-container {
flex-shrink: 0;
}
.artist-image {
width: 220px;
height: 220px;
border-radius: 50%;
object-fit: cover;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.artist-image-placeholder {
width: 220px;
height: 220px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
color: var(--text-muted);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.artist-actions {
display: flex;
gap: 12px;
margin-top: 20px;
}
.artist-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding-top: 8px;
}
.artist-name {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 16px 0;
text-align: left;
}
.favorite-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: var(--bg-tertiary);
border: none;
border-radius: 50%;
cursor: pointer;
color: var(--text-muted);
transition: all 150ms ease;
flex-shrink: 0;
}
.favorite-btn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--accent-primary);
}
.favorite-btn.is-favorite {
background: rgba(var(--accent-primary-rgb, 139, 92, 246), 0.15);
}
.favorite-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.radio-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: var(--bg-tertiary);
border: none;
border-radius: 50%;
cursor: pointer;
color: var(--text-muted);
transition: background 200ms ease, color 200ms ease;
flex-shrink: 0;
}
.radio-btn.loading {
cursor: default;
color: var(--accent-primary);
}
/* Outer rotating arc */
.radio-btn.loading::before {
content: '';
position: absolute;
inset: -4px;
border: 2px solid transparent;
border-top-color: var(--accent-primary);
border-right-color: var(--accent-primary);
border-radius: 50%;
animation: spinOuter 1.2s linear infinite;
pointer-events: none;
}
/* Inner rotating arc (opposite direction) */
.radio-btn.loading::after {
content: '';
position: absolute;
inset: -8px;
border: 2px solid transparent;
border-bottom-color: rgba(var(--accent-primary-rgb, 139, 92, 246), 0.5);
border-left-color: rgba(var(--accent-primary-rgb, 139, 92, 246), 0.5);
border-radius: 50%;
animation: spinInner 1.8s linear infinite reverse;
pointer-events: none;
}
@keyframes spinOuter {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes spinInner {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.radio-btn.glow {
color: var(--accent-primary);
box-shadow: 0 0 20px rgba(96, 165, 250, 0.4);
}
.radio-btn:hover:not(:disabled):not(.loading) {
background: var(--bg-hover);
color: var(--accent-primary);
}
.radio-btn:disabled:not(.loading) {
opacity: 0.5;
cursor: not-allowed;
}
.radio-btn-wrapper {
position: relative;
}
.network-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: var(--bg-tertiary);
border: none;
border-radius: 50%;
cursor: pointer;
color: var(--text-muted);
transition: background 200ms ease, color 200ms ease;
flex-shrink: 0;
}
.network-btn:hover {
background: var(--bg-hover);
color: var(--accent-primary);
}
.network-btn.active {
background: var(--bg-hover);
}
.network-btn .network-icon {
width: 24px;
height: 24px;
filter: brightness(0) saturate(100%) invert(70%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(90%) contrast(90%);
transition: filter 150ms ease;
}
.network-btn:hover .network-icon,
.network-btn.active .network-icon {
filter: brightness(0) saturate(100%) invert(56%) sepia(63%) saturate(4848%) hue-rotate(230deg) brightness(102%) contrast(101%);
}
.blacklist-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: var(--bg-tertiary);
border: none;
border-radius: 50%;
cursor: pointer;
color: var(--text-muted);
transition: all 150ms ease;
flex-shrink: 0;
}
.blacklist-btn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--error);
}
.blacklist-btn.is-blacklisted {
background: rgba(239, 68, 68, 0.15);
color: var(--error);
}
.blacklist-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.blacklist-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
margin: 0 24px 16px 24px;
color: #ef4444;
font-size: 13px;
}
.blacklist-banner span {
flex: 1;
}
.unblock-btn {
padding: 6px 14px;
background: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.4);
border-radius: 6px;
color: #ef4444;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
}
.unblock-btn:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.3);
}
.unblock-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.floating-message {
position: absolute;
left: 50%;
bottom: 100%;
transform: translateX(-50%);
white-space: nowrap;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
padding: 4px 10px;
background: var(--bg-tertiary);
border-radius: 12px;
pointer-events: none;
animation: floatUp 1.2s ease-out infinite;
}
@keyframes floatUp {
0% {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
20% {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
60% {
opacity: 1;
transform: translateX(-50%) translateY(-8px);
}
100% {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
}
.biography {
max-width: 100%;
margin-bottom: 16px;
font-weight: 300;
}
.bio-text {
font-size: 14px;
line-height: 1.7;
color: var(--text-secondary);
font-weight: 300;
/* Smart 2-line clamp */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.bio-text.expanded {
display: block;
-webkit-line-clamp: unset;
overflow: visible;
}
.bio-text :global(p) {
margin: 0 0 12px 0;
}
.bio-text :global(p:last-child) {
margin-bottom: 0;
}
.bio-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
font-weight: 400;
color: var(--accent-primary);
background: none;
border: none;
cursor: pointer;
padding: 6px 0;
margin-top: 4px;
}
.bio-toggle:hover {
text-decoration: underline;
}
.bio-source {
font-size: 11px;
color: var(--text-muted);
margin-top: 8px;
font-weight: 300;
}
.jump-nav {
position: sticky;
top: 0;
z-index: 4;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 10px 24px;
background: var(--bg-primary);
border-bottom: 1px solid var(--alpha-6);
box-shadow: 0 4px 8px -4px rgba(0, 0, 0, 0.5);
margin: 0 -8px 24px -24px;
width: calc(100% + 32px);
}
.jump-nav-left {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.jump-label {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.jump-links {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.jump-link {
padding: 4px 0;
border: none;
background: none;
color: var(--text-muted);
font-size: 13px;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 150ms ease, border-color 150ms ease;
}
.jump-link:hover {
color: var(--text-secondary);
}
.jump-link.active {
color: var(--text-primary);
border-bottom-color: var(--accent-primary);
}
/* Album Sort Dropdown */
.sort-dropdown {
position: relative;
}
.sort-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
transition: all 150ms ease;
}
.sort-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.sort-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 140px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 4px;
z-index: 50;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.sort-item {
width: 100%;
text-align: left;
padding: 8px 12px;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
border-radius: 4px;
font-size: 12px;
transition: all 100ms ease;
}
.sort-item:hover,
.sort-item.selected {
background: var(--bg-tertiary);
color: var(--text-primary);
}
/* Page Search */
.page-search {
display: flex;
align-items: center;
}
.search-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: var(--text-muted);
border-radius: 6px;
cursor: pointer;
transition: color 150ms ease;
}
.search-toggle:hover {
color: var(--text-primary);
}
.search-input-container {
display: flex;
align-items: center;
background: var(--bg-secondary);
border: 1px solid var(--bg-tertiary);
border-radius: 6px;
padding: 0 4px 0 12px;
animation: slideInFromRight 200ms ease-out;
}
@keyframes slideInFromRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.search-input {
width: 180px;
padding: 6px 0;
border: none;
background: transparent;
color: var(--text-primary);
font-size: 13px;
outline: none;
}
.search-input::placeholder {
color: var(--text-muted);
}
.search-controls {
display: flex;
align-items: center;
gap: 2px;
margin-left: 8px;
}
.search-count {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
min-width: 32px;
text-align: center;
padding: 0 4px;
}
.search-nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--text-muted);
border-radius: 4px;
cursor: pointer;
transition: all 150ms ease;
}
.search-nav-btn:hover:not(:disabled) {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.search-nav-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.search-close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--text-muted);
border-radius: 4px;
cursor: pointer;
transition: all 150ms ease;
margin-left: 2px;
}
.search-close-btn:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.divider {
height: 1px;
background: transparent;
margin: 32px 0;
}
.section-anchor {
scroll-margin-top: 140px;
}
.discography {
margin-bottom: 32px;
}
.discography:last-of-type {
margin-bottom: 0;
}
.playlists-section {
margin-bottom: 32px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 20px;
}
.section-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.section-title {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.section-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 8px;
background: var(--bg-tertiary);
border-radius: 12px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
.info-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: none;
border: none;
color: var(--text-muted);
cursor: help;
padding: 0;
margin-left: -4px;
}
.info-btn:hover {
color: var(--text-secondary);
}
.collapse-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0;
margin-left: -4px;
border-radius: 4px;
transition: background-color 150ms ease, color 150ms ease;
}
.collapse-btn:hover {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.no-albums {
color: var(--text-muted);
font-size: 14px;
}
.albums-grid {
display: flex;
flex-wrap: wrap;
gap: 24px 14px;
}
.playlists-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
.playlist-card {
display: flex;
gap: 12px;
padding: 10px;
border-radius: 10px;
border: 1px solid var(--bg-tertiary);
background-color: var(--bg-tertiary);
cursor: pointer;
text-align: left;
transition: background-color 150ms ease, border-color 150ms ease;
}
.playlist-card:hover:not(:disabled) {
background-color: var(--bg-hover);
border-color: var(--bg-hover);
}
.playlist-card:disabled {
opacity: 0.6;
cursor: default;
}
.playlist-artwork {
position: relative;
width: 56px;
height: 56px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.playlist-artwork img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 1;
}
.playlist-artwork-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg-secondary);
color: var(--text-muted);
}
.playlist-info {
min-width: 0;
}
.playlist-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-meta {
font-size: 12px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.load-more-container {
display: flex;
justify-content: center;
padding: 32px 0;
}
.load-more-btn {
padding: 12px 32px;
background-color: var(--bg-tertiary);
border: 1px solid var(--bg-tertiary);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
}
.load-more-btn:hover:not(:disabled) {
background-color: var(--accent-primary);
border-color: var(--accent-primary);
}
.load-more-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Top Tracks */
.top-tracks-section {
margin-top: 32px;
margin-bottom: 32px;
}
.section-header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.context-menu-wrapper {
position: relative;
}
.context-menu-backdrop {
position: fixed;
inset: 0;
z-index: 99;
}
.context-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
min-width: 160px;
background-color: var(--bg-tertiary);
border-radius: 8px;
padding: 2px 0;
z-index: 100;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.context-menu-item {
display: block;
width: 100%;
padding: 8px 12px;
background: none;
border: none;
text-align: left;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
transition: background-color 150ms ease, color 150ms ease;
}
.context-menu-item:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.load-more-link {
display: block;
width: 100%;
padding: 16px;
background: none;
border: none;
text-align: center;
font-size: 13px;
color: var(--text-muted);
cursor: pointer;
transition: color 150ms ease;
}
.load-more-link:hover {
color: var(--text-primary);
}
.tracks-loading {
color: var(--text-muted);
font-size: 14px;
padding: 16px 0;
}
.tracks-list {
display: flex;
flex-direction: column;
}
.track-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: none;
border: none;
border-radius: 8px;
cursor: pointer;
text-align: left;
width: 100%;
transition: background-color 150ms ease;
}
.track-row:hover {
background-color: var(--bg-tertiary);
}
.track-number {
width: 24px;
font-size: 14px;
color: var(--text-muted);
text-align: center;
}
.track-artwork {
width: 40px;
height: 40px;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
position: relative;
}
.track-artwork img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 1;
}
.track-artwork-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg-tertiary);
color: var(--text-muted);
}
.track-play-overlay {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
border: none;
cursor: pointer;
transition: background 150ms ease;
z-index: 2;
}
.track-row:hover .track-play-overlay {
display: flex;
}
.track-row.playing .track-play-overlay {
display: flex;
}
.track-play-overlay:hover {
background: rgba(0, 0, 0, 0.75);
}
.track-play-overlay .playing-indicator,
.track-play-overlay .pause-icon {
display: none;
}
.track-row.playing .track-play-overlay .play-icon {
display: none;
}
.track-row.playing .track-play-overlay .playing-indicator {
display: flex;
}
.track-row.playing:hover .track-play-overlay .playing-indicator {
display: none;
}
.track-row.playing:hover .track-play-overlay .pause-icon {
display: inline-flex;
}
.playing-indicator {
display: flex;
align-items: center;
gap: 2px;
}
.playing-indicator .bar {
width: 3px;
background-color: var(--accent-primary);
border-radius: 9999px;
transform-origin: bottom;
animation: artist-equalize 1s ease-in-out infinite;
}
.playing-indicator .bar:nth-child(1) {
height: 10px;
}
.playing-indicator .bar:nth-child(2) {
height: 14px;
animation-delay: 0.15s;
}
.playing-indicator .bar:nth-child(3) {
height: 8px;
animation-delay: 0.3s;
}
@keyframes artist-equalize {
0%, 100% {
transform: scaleY(0.5);
opacity: 0.7;
}
50% {
transform: scaleY(1);
opacity: 1;
}
}
.track-info {
flex: 1;
min-width: 0;
}
.track-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-album {
font-size: 12px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-link {
background: none;
border: none;
padding: 0;
text-align: left;
cursor: pointer;
}
.track-link:hover {
color: var(--text-primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.track-quality {
display: flex;
align-items: center;
}
.track-duration {
font-size: 13px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.track-actions {
display: flex;
align-items: center;
gap: 4px;
margin-left: 8px;
}
.track-favorite-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
opacity: 0.3;
transition: opacity 150ms ease, background-color 150ms ease;
}
.track-favorite-btn:hover {
opacity: 1;
background-color: var(--bg-tertiary);
}
.track-row:hover .track-favorite-btn {
opacity: 0.6;
}
.track-row:hover .track-favorite-btn:hover {
opacity: 1;
}
/* Responsive */
@media (max-width: 768px) {
.artist-header {
flex-direction: column;
align-items: center;
text-align: center;
}
.artist-name {
font-size: 24px;
}
.biography {
max-width: 100%;
}
}
</style>