- Sidebar: navigation labels, playlist section, sort options - SearchView: search placeholder, tabs, results messages - HomeView: section titles, loading states, empty states - QueuePanel: headers, search, empty state, actions - NowPlayingBar: player controls, tooltips, cast buttons - FocusMode: close button, lyrics loading - HomeSettingsModal: all settings labels and descriptions Added new translation keys to en.json and es.json locales.
854 lines
24 KiB
Svelte
854 lines
24 KiB
Svelte
<script lang="ts">
|
|
import { onMount, tick } from 'svelte';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { Search, Disc3, Music, Mic2, User, X } from 'lucide-svelte';
|
|
import AlbumCard from '../AlbumCard.svelte';
|
|
import TrackMenu from '../TrackMenu.svelte';
|
|
import { getSearchState, setSearchState, type SearchResults, type SearchTab } from '$lib/stores/searchState';
|
|
import { t } from '$lib/i18n';
|
|
|
|
let searchInput: HTMLInputElement | null = null;
|
|
|
|
onMount(async () => {
|
|
console.log('SearchView mounted!');
|
|
await tick();
|
|
searchInput?.focus();
|
|
});
|
|
|
|
// Track which images have failed to load
|
|
let failedTrackImages = $state<Set<number>>(new Set());
|
|
let failedArtistImages = $state<Set<number>>(new Set());
|
|
|
|
function handleTrackImageError(trackId: number) {
|
|
failedTrackImages = new Set([...failedTrackImages, trackId]);
|
|
}
|
|
|
|
function handleArtistImageError(artistId: number) {
|
|
failedArtistImages = new Set([...failedArtistImages, artistId]);
|
|
}
|
|
|
|
interface Props {
|
|
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;
|
|
onTrackPlay?: (track: Track) => void;
|
|
onTrackPlayNext?: (track: Track) => void;
|
|
onTrackPlayLater?: (track: Track) => void;
|
|
onTrackAddFavorite?: (trackId: number) => void;
|
|
onTrackAddToPlaylist?: (trackId: number) => void;
|
|
onTrackShareQobuz?: (trackId: number) => void;
|
|
onTrackShareSonglink?: (track: Track) => void;
|
|
onTrackGoToAlbum?: (albumId: string) => void;
|
|
onTrackGoToArtist?: (artistId: number) => void;
|
|
onArtistClick?: (artistId: number) => void;
|
|
}
|
|
|
|
let {
|
|
onAlbumClick,
|
|
onAlbumPlay,
|
|
onAlbumPlayNext,
|
|
onAlbumPlayLater,
|
|
onAlbumShareQobuz,
|
|
onAlbumShareSonglink,
|
|
onAlbumDownload,
|
|
onTrackPlay,
|
|
onTrackPlayNext,
|
|
onTrackPlayLater,
|
|
onTrackAddFavorite,
|
|
onTrackAddToPlaylist,
|
|
onTrackShareQobuz,
|
|
onTrackShareSonglink,
|
|
onTrackGoToAlbum,
|
|
onTrackGoToArtist,
|
|
onArtistClick
|
|
}: Props = $props();
|
|
|
|
interface Album {
|
|
id: string;
|
|
title: string;
|
|
artist: { name: string };
|
|
image: { small?: string; thumbnail?: string; large?: string };
|
|
release_date_original?: string;
|
|
hires_streamable?: boolean;
|
|
maximum_bit_depth?: number;
|
|
maximum_sampling_rate?: number;
|
|
}
|
|
|
|
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 Artist {
|
|
id: number;
|
|
name: string;
|
|
image?: { small?: string; thumbnail?: string; large?: string };
|
|
albums_count?: number;
|
|
}
|
|
|
|
const cachedState = getSearchState<Album, Track, Artist>();
|
|
|
|
let query = $state(cachedState.query ?? '');
|
|
let activeTab = $state<SearchTab>(cachedState.activeTab ?? 'albums');
|
|
let isSearching = $state(false);
|
|
let searchError = $state<string | null>(null);
|
|
|
|
let albumResults = $state<SearchResults<Album> | null>(cachedState.albumResults ?? null);
|
|
let trackResults = $state<SearchResults<Track> | null>(cachedState.trackResults ?? null);
|
|
let artistResults = $state<SearchResults<Artist> | null>(cachedState.artistResults ?? null);
|
|
|
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let isLoadingMore = $state(false);
|
|
const PAGE_SIZE = 20;
|
|
|
|
// Check if there are more results to load
|
|
let hasMoreAlbums = $derived(albumResults ? albumResults.offset + albumResults.items.length < albumResults.total : false);
|
|
let hasMoreTracks = $derived(trackResults ? trackResults.offset + trackResults.items.length < trackResults.total : false);
|
|
let hasMoreArtists = $derived(artistResults ? artistResults.offset + artistResults.items.length < artistResults.total : false);
|
|
|
|
function debounceSearch() {
|
|
if (searchTimeout) clearTimeout(searchTimeout);
|
|
if (query.trim().length < 2) {
|
|
albumResults = null;
|
|
trackResults = null;
|
|
artistResults = null;
|
|
return;
|
|
}
|
|
searchTimeout = setTimeout(() => performSearch(), 300);
|
|
}
|
|
|
|
function clearSearch() {
|
|
if (searchTimeout) {
|
|
clearTimeout(searchTimeout);
|
|
searchTimeout = null;
|
|
}
|
|
query = '';
|
|
searchError = null;
|
|
isSearching = false;
|
|
albumResults = null;
|
|
trackResults = null;
|
|
artistResults = null;
|
|
}
|
|
|
|
$effect(() => {
|
|
setSearchState<Album, Track, Artist>({
|
|
query,
|
|
activeTab,
|
|
albumResults,
|
|
trackResults,
|
|
artistResults
|
|
});
|
|
});
|
|
|
|
async function performSearch() {
|
|
if (!query.trim()) return;
|
|
|
|
isSearching = true;
|
|
searchError = null;
|
|
|
|
try {
|
|
// Search based on active tab - reset to first page
|
|
if (activeTab === 'albums') {
|
|
albumResults = await invoke<SearchResults<Album>>('search_albums', {
|
|
query: query.trim(),
|
|
limit: PAGE_SIZE,
|
|
offset: 0
|
|
});
|
|
console.log('Album results:', albumResults);
|
|
} else if (activeTab === 'tracks') {
|
|
trackResults = await invoke<SearchResults<Track>>('search_tracks', {
|
|
query: query.trim(),
|
|
limit: PAGE_SIZE,
|
|
offset: 0
|
|
});
|
|
console.log('Track results:', trackResults);
|
|
} else if (activeTab === 'artists') {
|
|
artistResults = await invoke<SearchResults<Artist>>('search_artists', {
|
|
query: query.trim(),
|
|
limit: PAGE_SIZE,
|
|
offset: 0
|
|
});
|
|
console.log('Artist results:', artistResults);
|
|
}
|
|
} catch (err) {
|
|
console.error('Search error:', err);
|
|
searchError = String(err);
|
|
} finally {
|
|
isSearching = false;
|
|
}
|
|
}
|
|
|
|
async function loadMore() {
|
|
if (!query.trim() || isLoadingMore) return;
|
|
|
|
isLoadingMore = true;
|
|
|
|
try {
|
|
if (activeTab === 'albums' && albumResults && hasMoreAlbums) {
|
|
const newOffset = albumResults.offset + albumResults.items.length;
|
|
const moreResults = await invoke<SearchResults<Album>>('search_albums', {
|
|
query: query.trim(),
|
|
limit: PAGE_SIZE,
|
|
offset: newOffset
|
|
});
|
|
albumResults = {
|
|
...moreResults,
|
|
items: [...albumResults.items, ...moreResults.items],
|
|
offset: 0 // Keep offset at 0 since we're accumulating
|
|
};
|
|
} else if (activeTab === 'tracks' && trackResults && hasMoreTracks) {
|
|
const newOffset = trackResults.offset + trackResults.items.length;
|
|
const moreResults = await invoke<SearchResults<Track>>('search_tracks', {
|
|
query: query.trim(),
|
|
limit: PAGE_SIZE,
|
|
offset: newOffset
|
|
});
|
|
trackResults = {
|
|
...moreResults,
|
|
items: [...trackResults.items, ...moreResults.items],
|
|
offset: 0
|
|
};
|
|
} else if (activeTab === 'artists' && artistResults && hasMoreArtists) {
|
|
const newOffset = artistResults.offset + artistResults.items.length;
|
|
const moreResults = await invoke<SearchResults<Artist>>('search_artists', {
|
|
query: query.trim(),
|
|
limit: PAGE_SIZE,
|
|
offset: newOffset
|
|
});
|
|
artistResults = {
|
|
...moreResults,
|
|
items: [...artistResults.items, ...moreResults.items],
|
|
offset: 0
|
|
};
|
|
}
|
|
} catch (err) {
|
|
console.error('Load more error:', err);
|
|
} finally {
|
|
isLoadingMore = false;
|
|
}
|
|
}
|
|
|
|
function handleTabChange(tab: typeof activeTab) {
|
|
activeTab = tab;
|
|
if (query.trim().length >= 2) {
|
|
performSearch();
|
|
}
|
|
}
|
|
|
|
function formatDuration(seconds: number): string {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
function getQualityLabel(track: Track | Album): string {
|
|
if (track.hires_streamable && track.maximum_bit_depth && track.maximum_sampling_rate) {
|
|
return `${track.maximum_bit_depth}bit/${track.maximum_sampling_rate}kHz`;
|
|
}
|
|
return 'CD Quality';
|
|
}
|
|
|
|
function getAlbumArtwork(album: Album): string {
|
|
return album.image?.large || album.image?.thumbnail || album.image?.small || '';
|
|
}
|
|
|
|
function getTrackArtwork(track: Track): string {
|
|
return track.album?.image?.large || track.album?.image?.thumbnail || track.album?.image?.small || '';
|
|
}
|
|
|
|
function getArtistImage(artist: Artist): string {
|
|
return artist.image?.large || artist.image?.thumbnail || artist.image?.small || '';
|
|
}
|
|
</script>
|
|
|
|
<div class="search-view">
|
|
<!-- Search Header -->
|
|
<div class="search-header">
|
|
<h1>{$t('search.title')}</h1>
|
|
<div class="search-input-container">
|
|
<Search size={20} class="search-icon" />
|
|
<input
|
|
type="text"
|
|
placeholder={$t('search.placeholder')}
|
|
bind:value={query}
|
|
oninput={debounceSearch}
|
|
class="search-input"
|
|
bind:this={searchInput}
|
|
/>
|
|
{#if query.trim()}
|
|
<button class="search-clear" onclick={clearSearch} aria-label={$t('actions.clear')}>
|
|
<X size={18} />
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<div class="tabs">
|
|
<button
|
|
class="tab"
|
|
class:active={activeTab === 'albums'}
|
|
onclick={() => handleTabChange('albums')}
|
|
>
|
|
<Disc3 size={18} />
|
|
<span>{$t('search.albums')}</span>
|
|
{#if albumResults}
|
|
<span class="count">{albumResults.total}</span>
|
|
{/if}
|
|
</button>
|
|
<button
|
|
class="tab"
|
|
class:active={activeTab === 'tracks'}
|
|
onclick={() => handleTabChange('tracks')}
|
|
>
|
|
<Music size={18} />
|
|
<span>{$t('search.tracks')}</span>
|
|
{#if trackResults}
|
|
<span class="count">{trackResults.total}</span>
|
|
{/if}
|
|
</button>
|
|
<button
|
|
class="tab"
|
|
class:active={activeTab === 'artists'}
|
|
onclick={() => handleTabChange('artists')}
|
|
>
|
|
<Mic2 size={18} />
|
|
<span>{$t('search.artists')}</span>
|
|
{#if artistResults}
|
|
<span class="count">{artistResults.total}</span>
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Results -->
|
|
<div class="results">
|
|
{#if isSearching}
|
|
<div class="loading">
|
|
<div class="spinner"></div>
|
|
<p>{$t('search.searching')}</p>
|
|
</div>
|
|
{:else if searchError}
|
|
<div class="error">
|
|
<p>{$t('errors.loadFailed')}</p>
|
|
<p class="error-detail">{searchError}</p>
|
|
</div>
|
|
{:else if !query.trim()}
|
|
<div class="empty-state">
|
|
<Search size={48} />
|
|
<p>{$t('search.startTyping')}</p>
|
|
</div>
|
|
{:else if activeTab === 'albums' && albumResults}
|
|
{#if albumResults.items.length === 0}
|
|
<div class="no-results">{$t('search.noAlbumsFor', { values: { query } })}</div>
|
|
{:else}
|
|
<div class="albums-grid">
|
|
{#each albumResults.items as album}
|
|
<AlbumCard
|
|
albumId={album.id}
|
|
artwork={getAlbumArtwork(album)}
|
|
title={album.title}
|
|
artist={album.artist?.name || 'Unknown Artist'}
|
|
quality={getQualityLabel(album)}
|
|
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
|
|
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
|
|
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
|
|
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
|
|
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
|
|
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
|
|
onclick={() => onAlbumClick?.(album.id)}
|
|
/>
|
|
{/each}
|
|
</div>
|
|
{#if hasMoreAlbums}
|
|
<div class="load-more-container">
|
|
<button class="load-more-btn" onclick={loadMore} disabled={isLoadingMore}>
|
|
{isLoadingMore ? $t('actions.loading') : $t('artist.loadMore') + ` (${albumResults.items.length} / ${albumResults.total})`}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
{:else if activeTab === 'tracks' && trackResults}
|
|
{#if trackResults.items.length === 0}
|
|
<div class="no-results">{$t('search.noTracksFor', { values: { query } })}</div>
|
|
{:else}
|
|
<div class="tracks-list">
|
|
{#each trackResults.items as track, index}
|
|
<div
|
|
class="track-row"
|
|
role="button"
|
|
tabindex="0"
|
|
onclick={() => onTrackPlay?.(track)}
|
|
onkeydown={(e) => e.key === 'Enter' && onTrackPlay?.(track)}
|
|
>
|
|
<div class="track-number">{index + 1}</div>
|
|
{#if failedTrackImages.has(track.id) || !getTrackArtwork(track)}
|
|
<div class="track-artwork-placeholder">
|
|
<Music size={20} />
|
|
</div>
|
|
{:else}
|
|
<img src={getTrackArtwork(track)} alt={track.title} class="track-artwork" onerror={() => handleTrackImageError(track.id)} />
|
|
{/if}
|
|
<div class="track-info">
|
|
<div class="track-title">{track.title}</div>
|
|
{#if track.performer?.id && onTrackGoToArtist}
|
|
<button
|
|
class="track-artist track-link"
|
|
type="button"
|
|
onclick={(event) => {
|
|
event.stopPropagation();
|
|
onTrackGoToArtist?.(track.performer!.id!);
|
|
}}
|
|
>
|
|
{track.performer?.name || 'Unknown Artist'}
|
|
</button>
|
|
{:else}
|
|
<div class="track-artist">{track.performer?.name || 'Unknown Artist'}</div>
|
|
{/if}
|
|
</div>
|
|
<div class="track-quality">{getQualityLabel(track)}</div>
|
|
<div class="track-duration">{formatDuration(track.duration)}</div>
|
|
<div class="track-actions">
|
|
<TrackMenu
|
|
onPlayNow={() => onTrackPlay?.(track)}
|
|
onPlayNext={onTrackPlayNext ? () => onTrackPlayNext(track) : undefined}
|
|
onPlayLater={onTrackPlayLater ? () => onTrackPlayLater(track) : undefined}
|
|
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 ? (() => { const albumId = track.album!.id!; return () => onTrackGoToAlbum(albumId); })() : undefined}
|
|
onGoToArtist={track.performer?.id && onTrackGoToArtist ? (() => { const artistId = track.performer!.id!; return () => onTrackGoToArtist(artistId); })() : undefined}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{#if hasMoreTracks}
|
|
<div class="load-more-container">
|
|
<button class="load-more-btn" onclick={loadMore} disabled={isLoadingMore}>
|
|
{isLoadingMore ? $t('actions.loading') : $t('artist.loadMore') + ` (${trackResults.items.length} / ${trackResults.total})`}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
{:else if activeTab === 'artists' && artistResults}
|
|
{#if artistResults.items.length === 0}
|
|
<div class="no-results">{$t('search.noArtistsFor', { values: { query } })}</div>
|
|
{:else}
|
|
<div class="artists-grid">
|
|
{#each artistResults.items as artist}
|
|
<button class="artist-card" onclick={() => onArtistClick?.(artist.id)}>
|
|
{#if failedArtistImages.has(artist.id) || !getArtistImage(artist)}
|
|
<div class="artist-image-placeholder">
|
|
<User size={40} />
|
|
</div>
|
|
{:else}
|
|
<img src={getArtistImage(artist)} alt={artist.name} class="artist-image" onerror={() => handleArtistImageError(artist.id)} />
|
|
{/if}
|
|
<div class="artist-name">{artist.name}</div>
|
|
{#if artist.albums_count}
|
|
<div class="artist-albums">{$t('library.albumCount', { values: { count: artist.albums_count } })}</div>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{#if hasMoreArtists}
|
|
<div class="load-more-container">
|
|
<button class="load-more-btn" onclick={loadMore} disabled={isLoadingMore}>
|
|
{isLoadingMore ? $t('actions.loading') : $t('artist.loadMore') + ` (${artistResults.items.length} / ${artistResults.total})`}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.search-view {
|
|
width: 100%;
|
|
height: 100%;
|
|
padding: 24px;
|
|
padding-right: 8px;
|
|
padding-bottom: 100px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
/* Custom scrollbar */
|
|
.search-view::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
|
|
.search-view::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
.search-view::-webkit-scrollbar-thumb {
|
|
background: var(--bg-tertiary);
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.search-view::-webkit-scrollbar-thumb:hover {
|
|
background: var(--text-muted);
|
|
}
|
|
|
|
.search-header {
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.search-header h1 {
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.search-input-container {
|
|
position: relative;
|
|
}
|
|
|
|
.search-input-container :global(.search-icon) {
|
|
position: absolute;
|
|
left: 16px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.search-input {
|
|
width: 100%;
|
|
height: 52px;
|
|
padding: 0 48px 0 48px;
|
|
background-color: var(--bg-secondary);
|
|
border: 1px solid var(--bg-tertiary);
|
|
border-radius: 12px;
|
|
font-size: 16px;
|
|
color: var(--text-primary);
|
|
outline: none;
|
|
transition: border-color 150ms ease;
|
|
}
|
|
|
|
.search-input:focus {
|
|
border-color: var(--accent-primary);
|
|
}
|
|
|
|
.search-input::placeholder {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.search-clear {
|
|
position: absolute;
|
|
right: 12px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 32px;
|
|
height: 32px;
|
|
border: none;
|
|
border-radius: 50%;
|
|
background: transparent;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
transition: background-color 150ms ease, color 150ms ease;
|
|
}
|
|
|
|
.search-clear:hover {
|
|
background-color: var(--bg-tertiary);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.tabs {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 24px;
|
|
border-bottom: 1px solid var(--bg-tertiary);
|
|
padding-bottom: 16px;
|
|
}
|
|
|
|
.tab {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 10px 16px;
|
|
background: none;
|
|
border: none;
|
|
border-radius: 8px;
|
|
color: var(--text-muted);
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 150ms ease;
|
|
}
|
|
|
|
.tab:hover {
|
|
color: var(--text-primary);
|
|
background-color: var(--bg-tertiary);
|
|
}
|
|
|
|
.tab.active {
|
|
color: var(--text-primary);
|
|
background-color: var(--accent-primary);
|
|
}
|
|
|
|
.tab .count {
|
|
padding: 2px 8px;
|
|
background-color: rgba(255, 255, 255, 0.2);
|
|
border-radius: 12px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.results {
|
|
min-height: 300px;
|
|
}
|
|
|
|
.loading, .empty-state, .error, .no-results {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 64px 0;
|
|
color: var(--text-muted);
|
|
gap: 16px;
|
|
}
|
|
|
|
.error {
|
|
color: #ff6b6b;
|
|
}
|
|
|
|
.error-detail {
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.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); }
|
|
}
|
|
|
|
.albums-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
gap: 24px;
|
|
}
|
|
|
|
.tracks-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.track-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
padding: 12px 16px;
|
|
background: none;
|
|
border: none;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: background-color 150ms ease;
|
|
width: 100%;
|
|
text-align: left;
|
|
}
|
|
|
|
.track-row:hover {
|
|
background-color: var(--bg-tertiary);
|
|
}
|
|
|
|
.track-number {
|
|
width: 32px;
|
|
font-size: 14px;
|
|
color: var(--text-muted);
|
|
text-align: center;
|
|
}
|
|
|
|
.track-artwork {
|
|
width: 48px;
|
|
height: 48px;
|
|
border-radius: 4px;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.track-artwork-placeholder {
|
|
width: 48px;
|
|
height: 48px;
|
|
border-radius: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.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-artist {
|
|
font-size: 13px;
|
|
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 {
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
padding: 4px 8px;
|
|
background-color: var(--bg-tertiary);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.track-duration {
|
|
font-size: 13px;
|
|
color: var(--text-muted);
|
|
font-family: var(--font-mono);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.track-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
margin-left: 8px;
|
|
opacity: 0.7;
|
|
transition: opacity 150ms ease;
|
|
}
|
|
|
|
.track-row:hover .track-actions {
|
|
opacity: 1;
|
|
}
|
|
|
|
.artists-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
gap: 24px;
|
|
}
|
|
|
|
.artist-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
text-align: center;
|
|
padding: 16px;
|
|
background-color: var(--bg-secondary);
|
|
border: none;
|
|
border-radius: 12px;
|
|
cursor: pointer;
|
|
transition: background-color 150ms ease;
|
|
}
|
|
|
|
.artist-card:hover {
|
|
background-color: var(--bg-tertiary);
|
|
}
|
|
|
|
.artist-image {
|
|
width: 120px;
|
|
height: 120px;
|
|
border-radius: 50%;
|
|
object-fit: cover;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.artist-image-placeholder {
|
|
width: 120px;
|
|
height: 120px;
|
|
border-radius: 50%;
|
|
margin-bottom: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.artist-name {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.artist-albums {
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
</style>
|