Files
qbz/src/lib/components/views/SearchView.svelte
T

3168 lines
105 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { resolveArtistImage } from '$lib/stores/customArtistImageStore';
import { cachedSrc } from '$lib/actions/cachedImage';
import { Search, Disc3, Music, Mic2, User, X, ChevronLeft, ChevronRight, Crown, Heart, Play, MoreHorizontal, ListPlus } from 'lucide-svelte';
import AlbumCard from '../AlbumCard.svelte';
import SearchPlaylistCard from '../SearchPlaylistCard.svelte';
import ViewTransition from '../ViewTransition.svelte';
import TrackMenu from '../TrackMenu.svelte';
import QualityBadge from '../QualityBadge.svelte';
import { replacePlaybackQueue } from '$lib/services/queuePlaybackService';
import { getSearchState, setSearchState, subscribeSearchFocus, subscribeSearchQuery, setSearchQuery, type SearchResults, type SearchAllResults, type SearchTab, type SearchFilterType, type Playlist } from '$lib/stores/searchState';
import { setPlaybackContext } from '$lib/stores/playbackContextStore';
import { togglePlay } from '$lib/stores/playerStore';
import { saveScrollPosition, getSavedScrollPosition } from '$lib/stores/navigationStore';
import { t } from '$lib/i18n';
let searchInput = $state<HTMLInputElement | null>(null);
let albumsCarouselContainer = $state<HTMLDivElement | null>(null);
let artistsCarouselContainer = $state<HTMLDivElement | null>(null);
let scrollContainer = $state<HTMLDivElement | null>(null);
let isScrolled = $state(false);
let currentAlbumPage = $state(0);
let currentArtistPage = $state(0);
let albumsPerPage = $state(5);
let artistsPerPage = $state(5);
// Right-click context menu state
let contextMenuTrackId = $state<number | null>(null);
let contextMenuPos = $state<{ x: number; y: number } | null>(null);
// NOTE: totalAlbumPages/totalArtistPages are defined after allResults declaration
onMount(() => {
console.log('SearchView mounted!');
// Async initialization in IIFE (doesn't block cleanup return)
(async () => {
await tick();
searchInput?.focus();
calculateAlbumsPerPage();
calculateArtistsPerPage();
// Restore scroll position
requestAnimationFrame(() => {
const saved = getSavedScrollPosition('search');
if (scrollContainer && saved > 0) {
scrollContainer.scrollTop = saved;
}
});
// Auto-search if query is pre-filled (e.g., from performer link)
if (query.trim().length >= 2 && !allResults && !albumResults && !trackResults && !artistResults) {
performSearch();
}
})();
// Synchronous subscriptions
window.addEventListener('resize', handleResize);
// Subscribe to focus trigger (when user re-clicks Search in sidebar)
const unsubscribeFocus = subscribeSearchFocus(() => {
scrollContainer?.scrollTo({ top: 0, behavior: 'smooth' });
searchInput?.focus();
});
// Subscribe to query changes from sidebar (1:1 sync)
const unsubscribeQuery = subscribeSearchQuery((newQuery) => {
if (newQuery !== query) {
query = newQuery;
debounceSearch();
}
});
return () => {
window.removeEventListener('resize', handleResize);
unsubscribeFocus();
unsubscribeQuery();
};
});
function handleScroll(event: Event) {
const target = event.target as HTMLDivElement;
isScrolled = target.scrollTop > 60;
saveScrollPosition('search', target.scrollTop);
// Feed virtual scroll calculations from the main scroll container
const viewportH = target.clientHeight;
if (albumsVirtualEl) {
const offset = albumsVirtualEl.offsetTop;
albumsScrollTop = Math.max(0, target.scrollTop - offset);
albumsContainerHeight = viewportH;
}
if (tracksVirtualEl) {
const offset = tracksVirtualEl.offsetTop;
tracksScrollTop = Math.max(0, target.scrollTop - offset);
tracksContainerHeight = viewportH;
}
if (artistsVirtualEl) {
const offset = artistsVirtualEl.offsetTop;
artistsScrollTop = Math.max(0, target.scrollTop - offset);
artistsContainerHeight = viewportH;
}
}
function handleResize() {
calculateAlbumsPerPage();
calculateArtistsPerPage();
}
function calculateAlbumsPerPage() {
if (!albumsCarouselContainer) return;
const containerWidth = albumsCarouselContainer.clientWidth;
const gap = 16;
const cardWidth = 160;
const cols = Math.floor((containerWidth + gap) / (cardWidth + gap));
albumsPerPage = Math.max(2, cols);
console.log(`Albums - Container width: ${containerWidth}px, Albums per page: ${albumsPerPage}`);
}
function calculateArtistsPerPage() {
if (!artistsCarouselContainer) return;
const containerWidth = artistsCarouselContainer.clientWidth;
const gap = 16;
const cardWidth = 160;
const cols = Math.floor((containerWidth + gap) / (cardWidth + gap));
artistsPerPage = Math.max(2, cols);
console.log(`Artists - Container width: ${containerWidth}px, Artists per page: ${artistsPerPage}`);
}
// Track which images have failed to load
let failedTrackImages = $state<Set<number>>(new Set());
let failedArtistImages = $state<Set<number>>(new Set());
// Most Popular card menu state
let mostPopularMenuOpen = $state(false);
let popularMenuTriggerRef = $state<HTMLButtonElement | null>(null);
let popularMenuEl = $state<HTMLDivElement | null>(null);
let popularMenuStyle = $state('');
// Most Popular card ticker animation state
let popularTitleRef: HTMLDivElement | null = $state(null);
let popularTitleTextRef: HTMLSpanElement | null = $state(null);
let popularTitleOverflow = $state(0);
let popularSubtitleRef: HTMLDivElement | null = $state(null);
let popularSubtitleTextRef: HTMLSpanElement | null = $state(null);
let popularSubtitleOverflow = $state(0);
const tickerSpeed = 40;
const popularTitleOffset = $derived(popularTitleOverflow > 0 ? `-${popularTitleOverflow + 16}px` : '0px');
const popularTitleDuration = $derived(popularTitleOverflow > 0 ? `${(popularTitleOverflow + 16) / tickerSpeed}s` : '0s');
const popularSubtitleOffset = $derived(popularSubtitleOverflow > 0 ? `-${popularSubtitleOverflow + 16}px` : '0px');
const popularSubtitleDuration = $derived(popularSubtitleOverflow > 0 ? `${(popularSubtitleOverflow + 16) / tickerSpeed}s` : '0s');
let popularOverflowMeasured = false;
function measurePopularOverflow() {
if (!popularOverflowMeasured) {
if (popularTitleRef && popularTitleTextRef) {
const overflow = popularTitleTextRef.scrollWidth - popularTitleRef.clientWidth;
popularTitleOverflow = overflow > 0 ? overflow : 0;
}
if (popularSubtitleRef && popularSubtitleTextRef) {
const overflow = popularSubtitleTextRef.scrollWidth - popularSubtitleRef.clientWidth;
popularSubtitleOverflow = overflow > 0 ? overflow : 0;
}
popularOverflowMeasured = true;
}
}
// Reset overflow measurement when most_popular content changes
$effect(() => {
allResults?.most_popular;
popularOverflowMeasured = false;
popularTitleOverflow = 0;
popularSubtitleOverflow = 0;
});
// Portal action - moves element to body to escape stacking context
function portal(node: HTMLElement) {
document.body.appendChild(node);
return {
destroy() {
if (node.parentNode) {
node.parentNode.removeChild(node);
}
}
};
}
async function positionPopularMenu() {
await tick();
if (!popularMenuTriggerRef || !popularMenuEl) return;
const triggerRect = popularMenuTriggerRef.getBoundingClientRect();
const menuRect = popularMenuEl.getBoundingClientRect();
const padding = 8;
// Position below the trigger button
let left = triggerRect.right - menuRect.width;
let top = triggerRect.bottom + 8;
// Keep within viewport
if (left < padding) left = padding;
if (left + menuRect.width > window.innerWidth - padding) {
left = window.innerWidth - menuRect.width - padding;
}
if (top + menuRect.height > window.innerHeight - padding) {
top = triggerRect.top - menuRect.height - 8;
}
if (top < padding) top = padding;
popularMenuStyle = `left: ${left}px; top: ${top}px;`;
}
function openPopularMenu() {
mostPopularMenuOpen = true;
positionPopularMenu();
}
function handleClickOutsidePopularMenu(event: MouseEvent) {
if (mostPopularMenuOpen) {
const target = event.target as HTMLElement;
if (!target.closest('.popular-menu') && !target.closest('.popular-menu-trigger')) {
mostPopularMenuOpen = false;
}
}
}
$effect(() => {
if (mostPopularMenuOpen) {
positionPopularMenu();
}
});
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;
onOpenAlbumFolder?: (albumId: string) => void;
onReDownloadAlbum?: (albumId: string) => void;
checkAlbumFullyDownloaded?: (albumId: string) => Promise<boolean>;
downloadStateVersion?: number;
onTrackPlay?: (track: Track) => 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;
onTrackShowInfo?: (trackId: number) => void;
onTrackDownload?: (track: Track) => void;
onTrackRemoveDownload?: (trackId: number) => void;
onTrackReDownload?: (track: Track) => void;
checkTrackDownloaded?: (trackId: number) => boolean;
onArtistClick?: (artistId: number) => void;
onPlaylistClick?: (playlistId: number) => void;
onPlaylistPlay?: (playlistId: number) => void;
onPlaylistPlayNext?: (playlistId: number) => void;
onPlaylistPlayLater?: (playlistId: number) => void;
onPlaylistCopyToLibrary?: (playlistId: number) => void;
onPlaylistShareQobuz?: (playlistId: number) => void;
activeTrackId?: number | null;
isPlaybackActive?: boolean;
searchInTitlebar?: boolean;
}
let {
onAlbumClick,
onAlbumPlay,
onAlbumPlayNext,
onAlbumPlayLater,
onAlbumShareQobuz,
onAlbumShareSonglink,
onAlbumDownload,
onOpenAlbumFolder,
onReDownloadAlbum,
checkAlbumFullyDownloaded,
downloadStateVersion,
onTrackPlay,
onTrackPlayNext,
onTrackPlayLater,
onTrackAddFavorite,
onTrackAddToPlaylist,
onAddAlbumToPlaylist,
onTrackShareQobuz,
onTrackShareSonglink,
onTrackGoToAlbum,
onTrackGoToArtist,
onTrackShowInfo,
onTrackDownload,
onTrackRemoveDownload,
onTrackReDownload,
checkTrackDownloaded,
onArtistClick,
onPlaylistClick,
onPlaylistPlay,
onPlaylistPlayNext,
onPlaylistPlayLater,
onPlaylistCopyToLibrary,
onPlaylistShareQobuz,
activeTrackId = null,
isPlaybackActive = false,
searchInTitlebar = false
}: Props = $props();
interface Album {
id: string;
title: string;
artist: { id?: number; name: string };
genre?: { name: string; };
image: { small?: string; thumbnail?: string; large?: string };
release_date_original?: string;
hires_streamable?: boolean;
maximum_bit_depth?: number;
maximum_sampling_rate?: number;
isViewMore?: boolean; // For "view more" placeholder
}
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;
isViewMore?: boolean; // For "view more" placeholder
}
const cachedState = getSearchState<Album, Track, Artist>();
let query = $state(cachedState.query ?? '');
let activeTab = $state<SearchTab>(cachedState.activeTab ?? 'all');
let filterType = $state<SearchFilterType>(cachedState.filterType ?? null);
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 playlistResults = $state<SearchResults<Playlist> | null>(cachedState.playlistResults ?? null);
let allResults = $state<SearchAllResults<Album, Track, Artist> | null>(cachedState.allResults ?? null);
let totalAlbumPages = $derived(allResults ? Math.ceil(allResults.albums.items.length / albumsPerPage) : 0);
let totalArtistPages = $derived(allResults ? Math.ceil(allResults.artists.items.length / artistsPerPage) : 0);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
let isLoadingMore = $state(false);
let searchVersion = $state(0); // Track search version to ignore stale results
let currentSearchQuery = $state(''); // Track current in-flight search
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);
let hasMorePlaylists = $derived(playlistResults ? playlistResults.offset + playlistResults.items.length < playlistResults.total : false);
// Most popular result comes directly from Qobuz's catalog/search API
// No heuristics needed - Qobuz determines the most relevant result
function debounceSearch() {
if (searchTimeout) clearTimeout(searchTimeout);
// Reset filter when user types a new search
filterType = null;
// Sync query to sidebar
setSearchQuery(query);
if (query.trim().length < 2) {
albumResults = null;
trackResults = null;
artistResults = null;
allResults = null;
return;
}
searchTimeout = setTimeout(() => performSearch(), 300);
}
function clearSearch() {
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
}
query = '';
filterType = null;
searchError = null;
isSearching = false;
albumResults = null;
trackResults = null;
artistResults = null;
allResults = null;
// Sync clear to sidebar
setSearchQuery('');
}
$effect(() => {
if (allResults) {
currentAlbumPage = 0;
currentArtistPage = 0;
setTimeout(() => {
calculateAlbumsPerPage();
calculateArtistsPerPage();
}, 100);
}
});
$effect(() => {
setSearchState<Album, Track, Artist>({
query,
activeTab,
filterType,
albumResults,
trackResults,
artistResults,
playlistResults,
allResults
});
});
// Download status tracking
let albumDownloadStatuses = $state<Map<string, boolean>>(new Map());
let downloadStatusTick = $state(0);
async function loadAlbumDownloadStatus(albumId: string) {
if (!checkAlbumFullyDownloaded) return false;
try {
const isDownloaded = await checkAlbumFullyDownloaded(albumId);
albumDownloadStatuses.set(albumId, isDownloaded);
downloadStatusTick++;
return isDownloaded;
} catch {
return false;
}
}
async function loadAllAlbumDownloadStatuses(albums: { id: string }[]) {
if (!checkAlbumFullyDownloaded || albums.length === 0) return;
const BATCH = 10;
for (let i = 0; i < albums.length; i += BATCH) {
await Promise.all(albums.slice(i, i + BATCH).map(album => loadAlbumDownloadStatus(album.id)));
if (i + BATCH < albums.length) await new Promise(r => setTimeout(r, 0));
}
}
function isAlbumDownloaded(albumId: string): boolean {
void downloadStateVersion;
void downloadStatusTick;
return albumDownloadStatuses.get(albumId) || false;
}
$effect(() => {
if (downloadStateVersion !== undefined && albumResults) {
loadAllAlbumDownloadStatuses(albumResults.items);
}
});
async function performSearch() {
const searchQuery = query.trim();
if (!searchQuery) return;
// Prevent duplicate concurrent searches for the same query
if (currentSearchQuery === searchQuery && isSearching) {
console.log(`Skipping duplicate search for "${searchQuery}"`);
return;
}
// Increment version to invalidate any in-flight requests
searchVersion++;
const thisSearchVersion = searchVersion;
currentSearchQuery = searchQuery;
isSearching = true;
searchError = null;
try {
// Search based on active tab - reset to first page
if (activeTab === 'all') {
// Use title case for better most_popular results from Qobuz API
const results = await invoke<SearchAllResults<Album, Track, Artist>>('v2_search_all', {
query: toTitleCase(searchQuery)
});
// Only apply if query hasn't changed while we were fetching
const currentQuery = query.trim();
if (currentQuery !== searchQuery) {
console.log(`[Search] Ignoring stale result for "${searchQuery}"`);
return;
}
// Preserve most_popular if new result doesn't have it but old one does
// (API can be inconsistent for the same query)
if (!results.most_popular && allResults?.most_popular) {
results.most_popular = allResults.most_popular;
}
allResults = results;
await tick(); // Yield to UI loop — render results before background work
if (allResults && allResults.albums.items) {
loadAllAlbumDownloadStatuses(allResults.albums.items); // fire-and-forget
}
} else if (activeTab === 'albums') {
const results = await invoke<SearchResults<Album>>('v2_search_albums', {
query: searchQuery,
limit: PAGE_SIZE,
offset: 0,
searchType: filterType
});
if (query.trim() !== searchQuery) return;
albumResults = results;
console.log('Album results:', albumResults);
await tick(); // Yield to UI loop
if (albumResults && albumResults.items) {
loadAllAlbumDownloadStatuses(albumResults.items); // fire-and-forget
}
} else if (activeTab === 'tracks') {
const results = await invoke<SearchResults<Track>>('v2_search_tracks', {
query: searchQuery,
limit: PAGE_SIZE,
offset: 0,
searchType: filterType
});
if (query.trim() !== searchQuery) return;
trackResults = results;
console.log('Track results:', trackResults);
} else if (activeTab === 'artists') {
const results = await invoke<SearchResults<Artist>>('v2_search_artists', {
query: searchQuery,
limit: PAGE_SIZE,
offset: 0,
searchType: filterType
});
if (query.trim() !== searchQuery) return;
artistResults = results;
console.log('Artist results:', artistResults);
} else if (activeTab === 'playlists') {
const results = await invoke<SearchResults<Playlist>>('v2_search_playlists', {
query: searchQuery,
limit: PAGE_SIZE,
offset: 0
});
if (query.trim() !== searchQuery) return;
playlistResults = results;
console.log('Playlist results:', playlistResults);
}
} catch (err) {
// Only show error if this is still the latest search
if (thisSearchVersion === searchVersion) {
console.error('Search error:', err);
searchError = String(err);
}
} finally {
// Only update loading state if this is still the latest search
if (thisSearchVersion === searchVersion) {
isSearching = false;
currentSearchQuery = '';
}
}
}
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>>('v2_search_albums', {
query: query.trim(),
limit: PAGE_SIZE,
offset: newOffset,
searchType: filterType
});
albumResults = {
...moreResults,
items: [...albumResults.items, ...moreResults.items],
offset: 0 // Keep offset at 0 since we're accumulating
};
loadAllAlbumDownloadStatuses(moreResults.items); // fire-and-forget
} else if (activeTab === 'tracks' && trackResults && hasMoreTracks) {
const newOffset = trackResults.offset + trackResults.items.length;
const moreResults = await invoke<SearchResults<Track>>('v2_search_tracks', {
query: query.trim(),
limit: PAGE_SIZE,
offset: newOffset,
searchType: filterType
});
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>>('v2_search_artists', {
query: query.trim(),
limit: PAGE_SIZE,
offset: newOffset,
searchType: filterType
});
artistResults = {
...moreResults,
items: [...artistResults.items, ...moreResults.items],
offset: 0
};
} else if (activeTab === 'playlists' && playlistResults && hasMorePlaylists) {
const newOffset = playlistResults.offset + playlistResults.items.length;
const moreResults = await invoke<SearchResults<Playlist>>('v2_search_playlists', {
query: query.trim(),
limit: PAGE_SIZE,
offset: newOffset
});
playlistResults = {
...moreResults,
items: [...playlistResults.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();
}
}
// Filter functions
function setFilter(type: SearchFilterType) {
filterType = type;
if (query.trim().length >= 2) {
performSearch();
}
}
function clearFilter() {
filterType = null;
if (query.trim().length >= 2) {
performSearch();
}
}
// Check if we have any results to show filters
let hasResults = $derived(
!!(allResults || albumResults || trackResults || artistResults)
);
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 getGenreLabel(album: Album): string {
return album.genre?.name || 'Unknown genre'
}
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?.small || album.image?.thumbnail || album.image?.large || '';
}
function getTrackArtwork(track: Track): string {
return track.album?.image?.small || track.album?.image?.thumbnail || track.album?.image?.large || '';
}
function buildSearchQueueTracks(tracks: Track[]) {
return tracks.map(track => ({
id: track.id,
title: track.title,
artist: track.performer?.name || 'Unknown Artist',
album: track.album?.title || '',
duration_secs: track.duration,
artwork_url: track.album?.image?.small || track.album?.image?.thumbnail || track.album?.image?.large || '',
hires: track.hires_streamable ?? false,
bit_depth: track.maximum_bit_depth ?? null,
sample_rate: track.maximum_sampling_rate ?? null,
is_local: false,
album_id: track.album?.id || null,
artist_id: track.performer?.id ?? null,
}));
}
async function handleSearchTrackPlay(track: Track, trackIndex: number) {
// Create search results context
if (trackResults && trackResults.items.length > 0) {
const trackIds = trackResults.items.map(track => track.id);
await setPlaybackContext(
'home_list', // Using home_list for search results (search type doesn't exist yet)
query,
`Search: ${query}`,
'qobuz',
trackIds,
trackIndex
);
console.log(`[Search] Context created: "${query}", ${trackIds.length} tracks, starting at ${trackIndex}`);
}
if (trackResults && trackResults.items.length > 0) {
try {
const queueTracks = buildSearchQueueTracks(trackResults.items);
await replacePlaybackQueue(queueTracks, trackIndex, {
debugLabel: 'search:results'
});
} catch (err) {
console.error('Failed to set queue:', err);
}
}
// Play track
if (onTrackPlay) {
onTrackPlay(track);
}
}
function handlePausePlayback(event: MouseEvent) {
event.stopPropagation();
void togglePlay();
}
function getArtistImage(artist: Artist): string {
const defaultUrl = artist.image?.small || artist.image?.thumbnail || artist.image?.large || '';
return resolveArtistImage(artist.name, defaultUrl);
}
// Title case query for better API results (Qobuz returns most_popular more consistently with proper capitalization)
function toTitleCase(str: string): string {
return str.replace(/\b\w/g, char => char.toUpperCase());
}
function scrollAlbumsCarousel(direction: 'left' | 'right') {
if (direction === 'left') {
currentAlbumPage = Math.max(0, currentAlbumPage - 1);
} else {
currentAlbumPage = Math.min(totalAlbumPagesWithViewMore - 1, currentAlbumPage + 1);
}
}
function scrollArtistsCarousel(direction: 'left' | 'right') {
if (direction === 'left') {
currentArtistPage = Math.max(0, currentArtistPage - 1);
} else {
currentArtistPage = Math.min(totalArtistPagesWithViewMore - 1, currentArtistPage + 1);
}
}
let showAlbumsViewMore = $derived(allResults ? allResults.albums.total > 30 : false);
let showArtistsViewMore = $derived(allResults ? allResults.artists.total > 12 : false);
let albumsWithViewMore = $derived(() => {
if (!allResults) return [];
const albums = [...allResults.albums.items];
if (showAlbumsViewMore) {
albums.push({ id: 'view-more', isViewMore: true } as any);
}
return albums;
});
let artistsWithViewMore = $derived(() => {
if (!allResults) return [];
const artists = [...allResults.artists.items];
if (showArtistsViewMore) {
artists.push({ id: 'view-more', isViewMore: true } as any);
}
return artists;
});
let totalAlbumPagesWithViewMore = $derived(albumsWithViewMore().length > 0 ? Math.ceil(albumsWithViewMore().length / albumsPerPage) : 0);
let totalArtistPagesWithViewMore = $derived(artistsWithViewMore().length > 0 ? Math.ceil(artistsWithViewMore().length / artistsPerPage) : 0);
let canScrollAlbumsLeft = $derived(currentAlbumPage > 0);
let canScrollAlbumsRight = $derived(currentAlbumPage < totalAlbumPagesWithViewMore - 1);
let canScrollArtistsLeft = $derived(currentArtistPage > 0);
let canScrollArtistsRight = $derived(currentArtistPage < totalArtistPagesWithViewMore - 1);
let visibleAlbums = $derived(
albumsWithViewMore().slice(currentAlbumPage * albumsPerPage, (currentAlbumPage + 1) * albumsPerPage)
);
let visibleArtists = $derived(
artistsWithViewMore().slice(currentArtistPage * artistsPerPage, (currentArtistPage + 1) * artistsPerPage)
);
// ==========================================
// Inline virtualization for tab result lists
// ==========================================
// Shared virtualization helpers
function vBinarySearchStart(tops: number[], heights: number[], targetTop: number): number {
let low = 0;
let high = tops.length - 1;
let result = 0;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
if (tops[mid] + heights[mid] > targetTop) {
result = mid;
high = mid - 1;
} else {
low = mid + 1;
}
}
return result;
}
function vBinarySearchEnd(tops: number[], targetBottom: number, startFrom: number): number {
let low = startFrom;
let high = tops.length - 1;
let result = high;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
if (tops[mid] > targetBottom) {
result = mid;
high = mid - 1;
} else {
low = mid + 1;
}
}
return result;
}
// --- Albums tab virtualization ---
const ALBUM_CARD_WIDTH = 210;
const ALBUM_CARD_HEIGHT = 310;
const ALBUM_GAP_X = 22;
const ALBUM_GAP_Y = 24;
const LOAD_MORE_HEIGHT = 80;
const V_BUFFER = 5;
let albumsVirtualEl: HTMLDivElement | null = $state(null);
let albumsScrollTop = $state(0);
let albumsContainerHeight = $state(0);
let albumsContainerWidth = $state(0);
let albumsResizeObs: ResizeObserver | null = null;
let lastKnownAlbumCols = 1;
let albumGridCols = $derived.by(() => {
if (albumsContainerWidth === 0) return lastKnownAlbumCols;
const cols = Math.max(1, Math.floor((albumsContainerWidth + ALBUM_GAP_X) / (ALBUM_CARD_WIDTH + ALBUM_GAP_X)));
lastKnownAlbumCols = cols;
return cols;
});
interface AlbumVirtualRow {
type: 'row';
startIdx: number;
count: number;
top: number;
height: number;
}
interface LoadMoreVirtualItem {
type: 'load-more';
top: number;
height: number;
}
type AlbumVirtualItem = AlbumVirtualRow | LoadMoreVirtualItem;
let albumVirtualItems = $derived.by((): AlbumVirtualItem[] => {
if (!albumResults) return [];
const items: AlbumVirtualItem[] = [];
const total = albumResults.items.length;
const cols = albumGridCols;
const rowH = ALBUM_CARD_HEIGHT + ALBUM_GAP_Y;
let top = 0;
for (let i = 0; i < total; i += cols) {
items.push({
type: 'row',
startIdx: i,
count: Math.min(cols, total - i),
top,
height: ALBUM_CARD_HEIGHT,
});
top += rowH;
}
if (hasMoreAlbums) {
items.push({ type: 'load-more', top, height: LOAD_MORE_HEIGHT });
}
return items;
});
let albumTotalHeight = $derived(
albumVirtualItems.length > 0
? albumVirtualItems[albumVirtualItems.length - 1].top + albumVirtualItems[albumVirtualItems.length - 1].height
: 0
);
let albumVisibleItems = $derived.by(() => {
if (albumVirtualItems.length === 0) return [];
const tops = albumVirtualItems.map(vi => vi.top);
const heights = albumVirtualItems.map(vi => vi.height);
const first = vBinarySearchStart(tops, heights, albumsScrollTop);
const last = vBinarySearchEnd(tops, albumsScrollTop + albumsContainerHeight, first);
const s = Math.max(0, first - V_BUFFER);
const e = Math.min(albumVirtualItems.length - 1, last + V_BUFFER);
return albumVirtualItems.slice(s, e + 1);
});
// --- Tracks tab virtualization ---
const TRACK_ROW_HEIGHT = 72;
const TRACK_ROW_GAP = 4;
let tracksVirtualEl: HTMLDivElement | null = $state(null);
let tracksScrollTop = $state(0);
let tracksContainerHeight = $state(0);
interface TrackVirtualItem {
type: 'track';
index: number;
top: number;
height: number;
}
type TrackOrLoadMore = TrackVirtualItem | LoadMoreVirtualItem;
let trackVirtualItems = $derived.by((): TrackOrLoadMore[] => {
if (!trackResults) return [];
const items: TrackOrLoadMore[] = [];
const rowH = TRACK_ROW_HEIGHT + TRACK_ROW_GAP;
let top = 0;
for (let i = 0; i < trackResults.items.length; i++) {
items.push({ type: 'track', index: i, top, height: TRACK_ROW_HEIGHT });
top += rowH;
}
if (hasMoreTracks) {
items.push({ type: 'load-more', top, height: LOAD_MORE_HEIGHT });
}
return items;
});
let trackTotalHeight = $derived(
trackVirtualItems.length > 0
? trackVirtualItems[trackVirtualItems.length - 1].top + trackVirtualItems[trackVirtualItems.length - 1].height
: 0
);
let trackVisibleItems = $derived.by(() => {
if (trackVirtualItems.length === 0) return [];
const tops = trackVirtualItems.map(vi => vi.top);
const heights = trackVirtualItems.map(vi => vi.height);
const first = vBinarySearchStart(tops, heights, tracksScrollTop);
const last = vBinarySearchEnd(tops, tracksScrollTop + tracksContainerHeight, first);
const s = Math.max(0, first - V_BUFFER);
const e = Math.min(trackVirtualItems.length - 1, last + V_BUFFER);
return trackVirtualItems.slice(s, e + 1);
});
// --- Artists tab virtualization ---
const ARTIST_CARD_WIDTH = 160;
const ARTIST_CARD_HEIGHT = 220;
const ARTIST_GAP = 24;
let artistsVirtualEl: HTMLDivElement | null = $state(null);
let artistsScrollTop = $state(0);
let artistsContainerHeight = $state(0);
let artistsContainerWidth = $state(0);
let artistsResizeObs: ResizeObserver | null = null;
let lastKnownArtistCols = 1;
let artistGridCols = $derived.by(() => {
if (artistsContainerWidth === 0) return lastKnownArtistCols;
const cols = Math.max(1, Math.floor((artistsContainerWidth + ARTIST_GAP) / (ARTIST_CARD_WIDTH + ARTIST_GAP)));
lastKnownArtistCols = cols;
return cols;
});
interface ArtistVirtualRow {
type: 'row';
startIdx: number;
count: number;
top: number;
height: number;
}
type ArtistVirtualItem = ArtistVirtualRow | LoadMoreVirtualItem;
let artistVirtualItems = $derived.by((): ArtistVirtualItem[] => {
if (!artistResults) return [];
const items: ArtistVirtualItem[] = [];
const total = artistResults.items.length;
const cols = artistGridCols;
const rowH = ARTIST_CARD_HEIGHT + ARTIST_GAP;
let top = 0;
for (let i = 0; i < total; i += cols) {
items.push({
type: 'row',
startIdx: i,
count: Math.min(cols, total - i),
top,
height: ARTIST_CARD_HEIGHT,
});
top += rowH;
}
if (hasMoreArtists) {
items.push({ type: 'load-more', top, height: LOAD_MORE_HEIGHT });
}
return items;
});
let artistTotalHeight = $derived(
artistVirtualItems.length > 0
? artistVirtualItems[artistVirtualItems.length - 1].top + artistVirtualItems[artistVirtualItems.length - 1].height
: 0
);
let artistVisibleItems = $derived.by(() => {
if (artistVirtualItems.length === 0) return [];
const tops = artistVirtualItems.map(vi => vi.top);
const heights = artistVirtualItems.map(vi => vi.height);
const first = vBinarySearchStart(tops, heights, artistsScrollTop);
const last = vBinarySearchEnd(tops, artistsScrollTop + artistsContainerHeight, first);
const s = Math.max(0, first - V_BUFFER);
const e = Math.min(artistVirtualItems.length - 1, last + V_BUFFER);
return artistVirtualItems.slice(s, e + 1);
});
// --- ResizeObserver setup/teardown ---
// Height comes from the parent scroll container (.search-view), not from virtual elements.
// ResizeObservers only track width for album/artist grid column calculations.
function setupResizeObservers() {
// Set initial container heights from the scroll container
if (scrollContainer) {
const viewportH = scrollContainer.clientHeight;
albumsContainerHeight = viewportH;
tracksContainerHeight = viewportH;
artistsContainerHeight = viewportH;
}
if (albumsVirtualEl) {
albumsResizeObs?.disconnect();
albumsContainerWidth = albumsVirtualEl.clientWidth;
albumsResizeObs = new ResizeObserver((entries) => {
for (const entry of entries) {
albumsContainerWidth = entry.contentRect.width;
}
});
albumsResizeObs.observe(albumsVirtualEl);
}
if (artistsVirtualEl) {
artistsResizeObs?.disconnect();
artistsContainerWidth = artistsVirtualEl.clientWidth;
artistsResizeObs = new ResizeObserver((entries) => {
for (const entry of entries) {
artistsContainerWidth = entry.contentRect.width;
}
});
artistsResizeObs.observe(artistsVirtualEl);
}
}
// Setup observers when elements bind
$effect(() => {
if (albumsVirtualEl || tracksVirtualEl || artistsVirtualEl) {
setupResizeObservers();
}
});
onDestroy(() => {
albumsResizeObs?.disconnect();
artistsResizeObs?.disconnect();
});
</script>
<svelte:window onclick={handleClickOutsidePopularMenu} />
<ViewTransition duration={200} distance={12} direction="up">
<div class="search-view" bind:this={scrollContainer} onscroll={handleScroll}>
<!-- Search Header - title only when not scrolled -->
{#if !isScrolled}
<div class="search-header">
<h1>{$t('search.title')}</h1>
</div>
{/if}
<!-- Sticky Header Container -->
<div class="sticky-header" class:scrolled={isScrolled}>
{#if !searchInTitlebar}
<div class="search-input-container" class:compact={isScrolled}>
<Search size={isScrolled ? 18 : 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>
{/if}
<!-- Tabs and Filters Row -->
<div class="tabs-row">
<div class="tabs">
<button
class="tab"
class:active={activeTab === 'all'}
onclick={() => handleTabChange('all')}
>
<span>All</span>
</button>
<button
class="tab"
class:active={activeTab === 'albums'}
onclick={() => handleTabChange('albums')}
>
<span>{$t('search.albums')}</span>
</button>
<button
class="tab"
class:active={activeTab === 'tracks'}
onclick={() => handleTabChange('tracks')}
>
<span>{$t('search.tracks')}</span>
</button>
<button
class="tab"
class:active={activeTab === 'artists'}
onclick={() => handleTabChange('artists')}
>
<span>{$t('search.artists')}</span>
</button>
<button
class="tab"
class:active={activeTab === 'playlists'}
onclick={() => handleTabChange('playlists')}
>
<span>Playlists</span>
</button>
</div>
{#if hasResults}
<div class="filters">
<label class="filter-option">
<input
type="radio"
name="searchFilter"
checked={filterType === 'MainArtist'}
onchange={() => setFilter('MainArtist')}
/>
<span>Artist</span>
</label>
<label class="filter-option">
<input
type="radio"
name="searchFilter"
checked={filterType === 'Performer'}
onchange={() => setFilter('Performer')}
/>
<span>Performer</span>
</label>
<label class="filter-option">
<input
type="radio"
name="searchFilter"
checked={filterType === 'Composer'}
onchange={() => setFilter('Composer')}
/>
<span>Composer</span>
</label>
<label class="filter-option">
<input
type="radio"
name="searchFilter"
checked={filterType === 'Label'}
onchange={() => setFilter('Label')}
/>
<span>Label</span>
</label>
<label class="filter-option">
<input
type="radio"
name="searchFilter"
checked={filterType === 'ReleaseName'}
onchange={() => setFilter('ReleaseName')}
/>
<span>Release</span>
</label>
<button
class="clear-filter-btn"
class:visible={filterType !== null}
onclick={clearFilter}
title="Clear filter"
disabled={!filterType}
>
<X size={14} />
</button>
</div>
{/if}
</div>
</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 === 'all' && allResults}
<!-- Unified Results View -->
<div class="unified-results">
<!-- Most Popular + Artists Section -->
<div class="top-section">
<div class="most-popular">
<div class="section-header">
<h3><Crown size={18} color="gold" /> Most Popular</h3>
</div>
<div class="most-popular-wrapper">
{#if allResults.most_popular?.type === 'artists'}
{@const artist = allResults.most_popular.content}
<button class="artist-card most-popular-card" onclick={() => onArtistClick?.(artist.id)}>
<div class="artist-image-wrapper">
<div class="artist-image-placeholder">
<User size={40} />
</div>
{#if !failedArtistImages.has(artist.id) && getArtistImage(artist)}
<img
use:cachedSrc={getArtistImage(artist)}
alt={artist.name}
class="artist-image"
loading="lazy"
decoding="async"
onerror={() => handleArtistImageError(artist.id)}
/>
{/if}
</div>
<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>
{:else if allResults.most_popular?.type === 'albums'}
{@const album = allResults.most_popular.content}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="popular-card most-popular-card"
class:menu-open={mostPopularMenuOpen}
onmouseenter={measurePopularOverflow}
onclick={(e) => {
const target = e.target as HTMLElement;
if (!target.closest('.popular-overlay-btn') && !target.closest('.popular-menu')) {
onAlbumClick?.(album.id);
}
}}
>
<div class="popular-card-artwork">
{#if getAlbumArtwork(album)}
<img
src={getAlbumArtwork(album)}
alt={album.title}
loading="lazy"
/>
{:else}
<div class="popular-card-placeholder">
<Disc3 size={32} />
</div>
{/if}
<div class="popular-card-overlay">
{#if album.genre?.name}
<div class="popular-overlay-genre">{album.genre.name}</div>
{/if}
<div class="popular-card-buttons">
<button
class="popular-overlay-btn"
type="button"
onclick={(e) => { e.stopPropagation(); /* TODO: Add favorite */ }}
title="Add to favorites"
>
<Heart size={16} />
</button>
<button
class="popular-overlay-btn popular-overlay-btn--play"
type="button"
onclick={(e) => { e.stopPropagation(); onAlbumPlay?.(album.id); }}
title="Play"
>
<Play size={18} fill="white" />
</button>
<button
class="popular-overlay-btn popular-menu-trigger"
type="button"
bind:this={popularMenuTriggerRef}
onclick={(e) => { e.stopPropagation(); mostPopularMenuOpen ? mostPopularMenuOpen = false : openPopularMenu(); }}
title="More options"
>
<MoreHorizontal size={16} />
</button>
{#if mostPopularMenuOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="popular-menu"
bind:this={popularMenuEl}
style={popularMenuStyle}
use:portal
role="menu"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
>
<button class="menu-item" onclick={() => { onAlbumPlay?.(album.id); mostPopularMenuOpen = false; }}>
<Play size={14} /> <span>{$t('actions.playNow')}</span>
</button>
<button class="menu-item" onclick={() => { onAlbumPlayNext?.(album.id); mostPopularMenuOpen = false; }}>
<ListPlus size={14} /> <span>{$t('actions.playNext')}</span>
</button>
<button class="menu-item" onclick={() => { onAlbumPlayLater?.(album.id); mostPopularMenuOpen = false; }}>
<ListPlus size={14} /> <span>{$t('actions.addToQueue')}</span>
</button>
{#if album.artist?.id}
<div class="separator"></div>
<button class="menu-item" onclick={() => { onArtistClick?.(album.artist.id!); mostPopularMenuOpen = false; }}>
<User size={14} /> <span>{$t('actions.goToArtist')}</span>
</button>
{/if}
</div>
{/if}
</div>
</div>
</div>
<button class="popular-card-info" onclick={() => onAlbumClick?.(album.id)}>
<div
class="popular-card-title"
class:scrollable={popularTitleOverflow > 0}
style="--ticker-offset: {popularTitleOffset}; --ticker-duration: {popularTitleDuration};"
bind:this={popularTitleRef}
>
<span class="popular-title-text" bind:this={popularTitleTextRef}>{album.title}</span>
</div>
<div
class="popular-card-subtitle"
class:scrollable={popularSubtitleOverflow > 0}
style="--ticker-offset: {popularSubtitleOffset}; --ticker-duration: {popularSubtitleDuration};"
bind:this={popularSubtitleRef}
>
<span class="popular-subtitle-text" bind:this={popularSubtitleTextRef}>{album.artist?.name || 'Unknown Artist'}</span>
</div>
</button>
{#if album.maximum_bit_depth || album.maximum_sampling_rate}
<div class="popular-quality-badge">
<QualityBadge
compact
bitDepth={album.maximum_bit_depth}
samplingRate={album.maximum_sampling_rate}
/>
</div>
{/if}
</div>
{:else if allResults.most_popular?.type === 'tracks'}
{@const track = allResults.most_popular.content}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="popular-card most-popular-card" class:menu-open={mostPopularMenuOpen} onmouseenter={measurePopularOverflow}>
<div class="popular-card-artwork">
{#if track.album?.image?.large || track.album?.image?.small}
<img
src={track.album.image.large || track.album.image.small}
alt={track.album?.title || track.title}
loading="lazy"
/>
{:else}
<div class="popular-card-placeholder">
<Music size={32} />
</div>
{/if}
<div class="popular-card-overlay">
<div class="popular-card-buttons">
<button
class="popular-overlay-btn"
type="button"
onclick={(e) => { e.stopPropagation(); onTrackAddFavorite?.(track.id); }}
title="Add to favorites"
>
<Heart size={16} />
</button>
<button
class="popular-overlay-btn popular-overlay-btn--play"
type="button"
onclick={(e) => { e.stopPropagation(); handleSearchTrackPlay(track, 0); }}
title="Play"
>
<Play size={18} fill="white" />
</button>
<button
class="popular-overlay-btn popular-menu-trigger"
type="button"
bind:this={popularMenuTriggerRef}
onclick={(e) => { e.stopPropagation(); mostPopularMenuOpen ? mostPopularMenuOpen = false : openPopularMenu(); }}
title="More options"
>
<MoreHorizontal size={16} />
</button>
{#if mostPopularMenuOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="popular-menu"
bind:this={popularMenuEl}
style={popularMenuStyle}
use:portal
role="menu"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
>
<button class="menu-item" onclick={() => { handleSearchTrackPlay(track, 0); mostPopularMenuOpen = false; }}>
<Play size={14} /> <span>{$t('actions.playNow')}</span>
</button>
<button class="menu-item" onclick={() => { onTrackPlayNext?.(track); mostPopularMenuOpen = false; }}>
<ListPlus size={14} /> <span>{$t('actions.playNext')}</span>
</button>
<button class="menu-item" onclick={() => { onTrackPlayLater?.(track); mostPopularMenuOpen = false; }}>
<ListPlus size={14} /> <span>{$t('actions.addToQueue')}</span>
</button>
{#if track.album?.id}
<div class="separator"></div>
<button class="menu-item" onclick={() => { onTrackGoToAlbum?.(track.album!.id!); mostPopularMenuOpen = false; }}>
<Disc3 size={14} /> <span>{$t('actions.goToAlbum')}</span>
</button>
{/if}
</div>
{/if}
</div>
</div>
</div>
<div class="popular-card-info">
<div
class="popular-card-title"
class:scrollable={popularTitleOverflow > 0}
style="--ticker-offset: {popularTitleOffset}; --ticker-duration: {popularTitleDuration};"
bind:this={popularTitleRef}
>
<span class="popular-title-text" bind:this={popularTitleTextRef}>{track.title}</span>
</div>
<div
class="popular-card-subtitle"
class:scrollable={popularSubtitleOverflow > 0}
style="--ticker-offset: {popularSubtitleOffset}; --ticker-duration: {popularSubtitleDuration};"
bind:this={popularSubtitleRef}
>
<span class="popular-subtitle-text" bind:this={popularSubtitleTextRef}>{track.performer?.name || 'Unknown Artist'}</span>
</div>
</div>
{#if track.maximum_bit_depth || track.maximum_sampling_rate}
<div class="popular-quality-badge">
<QualityBadge
compact
bitDepth={track.maximum_bit_depth}
samplingRate={track.maximum_sampling_rate}
/>
</div>
{/if}
</div>
{/if}
</div>
</div>
<div class="artists-section">
<div class="section-header">
<h3>Artists</h3>
<div class="carousel-controls">
<button
class="carousel-btn"
onclick={() => scrollArtistsCarousel('left')}
disabled={!canScrollArtistsLeft}
aria-label="Previous artists"
>
<ChevronLeft size={20} />
</button>
<button
class="carousel-btn"
onclick={() => scrollArtistsCarousel('right')}
disabled={!canScrollArtistsRight}
aria-label="Next artists"
>
<ChevronRight size={20} />
</button>
<button class="view-all-link" onclick={() => handleTabChange('artists')}>
View all ({allResults.artists.total})
</button>
</div>
</div>
<div class="artists-carousel-wrapper" bind:this={artistsCarouselContainer}>
<div class="artists-carousel">
{#each visibleArtists as artist}
{#if artist.isViewMore}
<div class="view-more-card">
<button class="view-more-cover" onclick={() => handleTabChange('artists')}>
<div class="view-more-label">
<span>{$t('search.viewMore')}</span>
<ChevronRight size={20} />
</div>
</button>
<div class="view-more-info">
<span class="view-more-text">{allResults.artists.total - 12} more artists to discover</span>
</div>
</div>
{:else}
<button class="artist-card" onclick={() => onArtistClick?.(artist.id)}>
<div class="artist-image-wrapper">
<!-- Placeholder always visible as background -->
<div class="artist-image-placeholder">
<User size={40} />
</div>
<!-- Image overlays placeholder when loaded -->
{#if !failedArtistImages.has(artist.id) && getArtistImage(artist)}
<img use:cachedSrc={getArtistImage(artist)} alt={artist.name} class="artist-image" loading="lazy" decoding="async" onerror={() => handleArtistImageError(artist.id)} />
{/if}
</div>
<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>
{/if}
{/each}
</div>
</div>
</div>
</div>
<!-- Albums + Tracks Section (50/50) -->
<div class="bottom-section">
<!-- Albums Carousel with Navigation -->
{#if allResults.albums.items.length > 0}
<div class="albums-section">
<div class="section-header">
<h3>Albums</h3>
<div class="carousel-controls">
<button
class="carousel-btn"
onclick={() => scrollAlbumsCarousel('left')}
disabled={!canScrollAlbumsLeft}
aria-label="Previous albums"
>
<ChevronLeft size={20} />
</button>
<button
class="carousel-btn"
onclick={() => scrollAlbumsCarousel('right')}
disabled={!canScrollAlbumsRight}
aria-label="Next albums"
>
<ChevronRight size={20} />
</button>
<button class="view-all-link" onclick={() => handleTabChange('albums')}>
View all ({allResults.albums.total})
</button>
</div>
</div>
<div class="albums-carousel-wrapper" bind:this={albumsCarouselContainer}>
<div class="albums-carousel">
{#each visibleAlbums as album}
{#if album.isViewMore}
<div class="album-card-wrapper">
<div class="view-more-card">
<button class="view-more-cover" onclick={() => handleTabChange('albums')}>
<div class="view-more-label">
<span>{$t('search.viewMore')}</span>
<ChevronRight size={20} />
</div>
</button>
<div class="view-more-info">
<span class="view-more-text">{allResults.albums.total - 30} more albums to discover</span>
</div>
</div>
</div>
{:else}
<div class="album-card-wrapper">
<AlbumCard
albumId={album.id}
artwork={getAlbumArtwork(album)}
title={album.title}
artist={album.artist?.name || 'Unknown Artist'}
artistId={album.artist?.id}
onArtistClick={onArtistClick}
genre={getGenreLabel(album)}
releaseDate={album.release_date_original}
size="large"
quality={getQualityLabel(album)}
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); }}
/>
</div>
{/if}
{/each}
</div>
</div>
</div>
{/if}
<!-- Tracks Section -->
{#if allResults.tracks.items.length > 0}
<div class="tracks-section">
<div class="section-header">
<h3>Tracks</h3>
<button class="view-all-link" onclick={() => handleTabChange('tracks')}>
View all ({allResults.tracks.total})
</button>
</div>
<div class="tracks-list-compact">
{#each allResults.tracks.items.slice(0, 6) as track, index}
{@const isActiveTrack = isPlaybackActive && activeTrackId === track.id}
{@const isTrackDownloaded = checkTrackDownloaded?.(track.id) || false}
<div
class="track-row"
class:playing={isActiveTrack}
role="button"
tabindex="0"
onclick={() => handleSearchTrackPlay(track, index)}
onkeydown={(e) => e.key === 'Enter' && handleSearchTrackPlay(track, index)}
oncontextmenu={(e) => {
e.preventDefault();
contextMenuTrackId = track.id;
contextMenuPos = { x: e.clientX, y: e.clientY };
}}
>
<div class="track-number">{index + 1}</div>
<div class="track-artwork-container">
<!-- Placeholder always visible as background -->
<div class="track-artwork-placeholder">
<Music size={20} />
</div>
<!-- Image overlays placeholder when loaded -->
{#if !failedTrackImages.has(track.id) && getTrackArtwork(track)}
<img src={getTrackArtwork(track)} alt={track.title} class="track-artwork" loading="lazy" decoding="async" onerror={() => handleTrackImageError(track.id)} />
{/if}
<button
class="track-play-overlay"
class:is-playing={isActiveTrack}
onclick={(e) => {
if (isActiveTrack) {
handlePausePlayback(e);
} else {
e.stopPropagation();
handleSearchTrackPlay(track, index);
}
}}
aria-label={isActiveTrack ? 'Pause track' : 'Play track'}
>
<span class="play-icon" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="white">
<path d="M8 5v14l11-7z"/>
</svg>
</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="20" height="20" 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.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">
<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">
<TrackMenu
onPlayNow={() => handleSearchTrackPlay(track, index)}
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}
onShowInfo={onTrackShowInfo ? () => onTrackShowInfo(track.id) : undefined}
onDownload={onTrackDownload ? () => onTrackDownload(track) : undefined}
isTrackDownloaded={isTrackDownloaded}
onReDownload={isTrackDownloaded && onTrackReDownload ? () => onTrackReDownload(track) : undefined}
onRemoveDownload={isTrackDownloaded && onTrackRemoveDownload ? () => onTrackRemoveDownload(track.id) : undefined}
contextMenuPosition={contextMenuTrackId === track.id ? contextMenuPos : null}
onContextMenuClosed={() => { contextMenuTrackId = null; contextMenuPos = null; }}
/>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
<!-- Playlists Section (in unified view) -->
{#if allResults.playlists && allResults.playlists.items.length > 0}
<div class="playlists-section">
<div class="section-header">
<h3>Playlists</h3>
<button class="view-all-link" onclick={() => handleTabChange('playlists')}>
View all ({allResults.playlists.total})
</button>
</div>
<div class="playlists-preview-grid">
{#each allResults.playlists.items.slice(0, 6) as playlist}
<SearchPlaylistCard
{playlist}
onclick={() => onPlaylistClick?.(playlist.id)}
onPlay={() => onPlaylistPlay?.(playlist.id)}
onPlayNext={() => onPlaylistPlayNext?.(playlist.id)}
onPlayLater={() => onPlaylistPlayLater?.(playlist.id)}
onCopyToLibrary={() => onPlaylistCopyToLibrary?.(playlist.id)}
onShareQobuz={() => onPlaylistShareQobuz?.(playlist.id)}
/>
{/each}
</div>
</div>
{/if}
</div>
{:else if activeTab === 'albums' && albumResults}
{#if albumResults.items.length === 0}
<div class="no-results">{$t('search.noAlbumsFor', { values: { query } })}</div>
{:else}
<div class="virtual-scroll-container" bind:this={albumsVirtualEl}>
<div class="virtual-scroll-content" style="height: {albumTotalHeight}px;">
{#each albumVisibleItems as vItem (vItem.type === 'row' ? `row-${vItem.startIdx}` : 'load-more')}
<div class="virtual-scroll-item" style="transform: translateY({vItem.top}px); height: {vItem.height}px;">
{#if vItem.type === 'row'}
<div class="albums-grid-row">
{#each albumResults.items.slice(vItem.startIdx, vItem.startIdx + vItem.count) as album (album.id)}
<AlbumCard
albumId={album.id}
artwork={getAlbumArtwork(album)}
title={album.title}
artist={album.artist?.name || 'Unknown Artist'}
genre={getGenreLabel(album)}
releaseDate={album.release_date_original}
size="large"
quality={getQualityLabel(album)}
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>
{:else}
<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}
</div>
{/each}
</div>
</div>
{/if}
{:else if activeTab === 'tracks' && trackResults}
{#if trackResults.items.length === 0}
<div class="no-results">{$t('search.noTracksFor', { values: { query } })}</div>
{:else}
<div class="virtual-scroll-container" bind:this={tracksVirtualEl}>
<div class="virtual-scroll-content" style="height: {trackTotalHeight}px;">
{#each trackVisibleItems as vItem (vItem.type === 'track' ? `track-${vItem.index}` : 'load-more')}
<div class="virtual-scroll-item" style="transform: translateY({vItem.top}px); height: {vItem.height}px;">
{#if vItem.type === 'track'}
{@const track = trackResults.items[vItem.index]}
{@const index = vItem.index}
{@const isActiveTrack = isPlaybackActive && activeTrackId === track.id}
{@const isTrackDownloaded = checkTrackDownloaded?.(track.id) || false}
<div
class="track-row"
class:playing={isActiveTrack}
role="button"
tabindex="0"
onclick={() => handleSearchTrackPlay(track, index)}
onkeydown={(e) => e.key === 'Enter' && handleSearchTrackPlay(track, index)}
oncontextmenu={(e) => {
e.preventDefault();
contextMenuTrackId = track.id;
contextMenuPos = { x: e.clientX, y: e.clientY };
}}
>
<div class="track-number">{index + 1}</div>
<div class="track-artwork-container">
<div class="track-artwork-placeholder">
<Music size={20} />
</div>
{#if !failedTrackImages.has(track.id) && getTrackArtwork(track)}
<img src={getTrackArtwork(track)} alt={track.title} class="track-artwork" loading="lazy" decoding="async" onerror={() => handleTrackImageError(track.id)} />
{/if}
<button
class="track-play-overlay"
class:is-playing={isActiveTrack}
onclick={(e) => {
if (isActiveTrack) {
handlePausePlayback(e);
} else {
e.stopPropagation();
handleSearchTrackPlay(track, index);
}
}}
aria-label={isActiveTrack ? 'Pause track' : 'Play track'}
>
<span class="play-icon" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="white">
<path d="M8 5v14l11-7z"/>
</svg>
</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="20" height="20" 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.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">
<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">
<TrackMenu
onPlayNow={() => handleSearchTrackPlay(track, index)}
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}
onShowInfo={onTrackShowInfo ? () => onTrackShowInfo(track.id) : undefined}
onDownload={onTrackDownload ? () => onTrackDownload(track) : undefined}
isTrackDownloaded={isTrackDownloaded}
onReDownload={isTrackDownloaded && onTrackReDownload ? () => onTrackReDownload(track) : undefined}
onRemoveDownload={isTrackDownloaded && onTrackRemoveDownload ? () => onTrackRemoveDownload(track.id) : undefined}
contextMenuPosition={contextMenuTrackId === track.id ? contextMenuPos : null}
onContextMenuClosed={() => { contextMenuTrackId = null; contextMenuPos = null; }}
/>
</div>
</div>
{:else}
<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}
</div>
{/each}
</div>
</div>
{/if}
{:else if activeTab === 'artists' && artistResults}
{#if artistResults.items.length === 0}
<div class="no-results">{$t('search.noArtistsFor', { values: { query } })}</div>
{:else}
<div class="virtual-scroll-container" bind:this={artistsVirtualEl}>
<div class="virtual-scroll-content" style="height: {artistTotalHeight}px;">
{#each artistVisibleItems as vItem (vItem.type === 'row' ? `row-${vItem.startIdx}` : 'load-more')}
<div class="virtual-scroll-item" style="transform: translateY({vItem.top}px); height: {vItem.height}px;">
{#if vItem.type === 'row'}
<div class="artists-grid-row">
{#each artistResults.items.slice(vItem.startIdx, vItem.startIdx + vItem.count) as artist (artist.id)}
<button class="artist-card" onclick={() => onArtistClick?.(artist.id)}>
<div class="artist-image-wrapper">
<div class="artist-image-placeholder">
<User size={40} />
</div>
{#if !failedArtistImages.has(artist.id) && getArtistImage(artist)}
<img use:cachedSrc={getArtistImage(artist)} alt={artist.name} class="artist-image" loading="lazy" decoding="async" onerror={() => handleArtistImageError(artist.id)} />
{/if}
</div>
<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>
{:else}
<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}
</div>
{/each}
</div>
</div>
{/if}
{:else if activeTab === 'playlists' && playlistResults}
{#if playlistResults.items.length === 0}
<div class="no-results">{$t('search.noPlaylistsFor', { values: { query } })}</div>
{:else}
<div class="playlists-grid">
{#each playlistResults.items as playlist}
<SearchPlaylistCard
{playlist}
onclick={() => onPlaylistClick?.(playlist.id)}
onPlay={() => onPlaylistPlay?.(playlist.id)}
onPlayNext={() => onPlaylistPlayNext?.(playlist.id)}
onPlayLater={() => onPlaylistPlayLater?.(playlist.id)}
onCopyToLibrary={() => onPlaylistCopyToLibrary?.(playlist.id)}
onShareQobuz={() => onPlaylistShareQobuz?.(playlist.id)}
/>
{/each}
</div>
{#if hasMorePlaylists}
<div class="load-more-container">
<button class="load-more-btn" onclick={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? $t('actions.loading') : $t('artist.loadMore') + ` (${playlistResults.items.length} / ${playlistResults.total})`}
</button>
</div>
{/if}
{/if}
{/if}
</div>
</div>
</ViewTransition>
<style>
.search-view {
width: 100%;
height: 100%;
padding: 8px 24px 100px 18px;
overflow-y: scroll;
}
/* 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: 16px;
}
.search-header h1 {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0;
}
/* Sticky header */
.sticky-header {
position: sticky;
top: -24px;
z-index: 100;
padding-top: 0;
margin-left: -18px;
margin-right: -24px;
padding-left: 18px;
padding-right: 24px;
transition: background-color 150ms ease, padding-top 150ms ease, box-shadow 150ms ease;
}
.sticky-header.scrolled {
top: -24px;
background-color: var(--bg-primary);
padding-top: 24px;
padding-bottom: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.search-input-container {
position: relative;
margin-bottom: 16px;
transition: margin-bottom 150ms ease;
}
.search-input-container.compact {
margin-bottom: 12px;
}
.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: color 150ms ease, background-color 150ms ease, border-color 150ms ease, opacity 150ms ease;
}
.search-input:focus {
border-color: var(--accent-primary);
}
.search-input::placeholder {
color: var(--text-muted);
}
/* Compact search input when scrolled */
.search-input-container.compact .search-input {
height: 40px;
padding: 0 40px 0 40px;
background-color: transparent;
border: none;
border-bottom: 2px solid var(--text-muted);
border-radius: 0;
font-size: 15px;
}
.search-input-container.compact .search-input:focus {
border-bottom-color: var(--accent-muted, var(--text-secondary));
}
.search-input-container.compact :global(.search-icon) {
left: 8px;
}
.search-input-container.compact .search-clear {
right: 4px;
width: 28px;
height: 28px;
}
.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-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 0;
border-bottom: 1px solid var(--bg-tertiary);
padding-bottom: 12px;
}
.sticky-header:not(.scrolled) .tabs-row {
margin-bottom: 24px;
}
.tabs {
display: flex;
gap: 4px;
}
.tab {
display: flex;
align-items: center;
padding: 10px 12px;
background: none;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
color: var(--text-muted);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease, opacity 150ms ease;
}
.tab:hover {
color: var(--text-primary);
}
.tab.active {
color: var(--text-primary);
border-bottom-color: var(--accent-primary);
}
/* Filters */
.filters {
display: flex;
align-items: center;
gap: 16px;
}
.filter-option {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 12px;
color: var(--text-secondary);
transition: color 150ms ease;
}
.filter-option input[type="radio"] {
accent-color: var(--accent-primary);
cursor: pointer;
margin: 0;
}
.filter-option:has(input:checked) {
color: var(--text-primary);
}
.filter-option:hover {
color: var(--text-primary);
}
.clear-filter-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--bg-tertiary);
border: none;
color: var(--text-muted);
cursor: pointer;
transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease, opacity 150ms ease;
opacity: 0;
pointer-events: none;
}
.clear-filter-btn.visible {
opacity: 1;
pointer-events: auto;
}
.clear-filter-btn.visible:hover {
background: var(--bg-quaternary, var(--bg-tertiary));
color: var(--text-primary);
}
.results {
min-height: 300px;
padding-top: 16px;
}
.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); }
}
/* Virtual scroll containers for tab results */
.virtual-scroll-container {
position: relative;
}
.virtual-scroll-content {
position: relative;
width: 100%;
}
.virtual-scroll-item {
position: absolute;
left: 0;
right: 0;
will-change: transform;
}
.albums-grid-row {
display: flex;
flex-wrap: wrap;
gap: 24px 22px;
}
.artists-grid-row {
display: flex;
gap: 24px;
}
.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 {
position: absolute;
inset: 0;
width: 48px;
height: 48px;
border-radius: 4px;
object-fit: cover;
z-index: 1;
}
.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 {
display: flex;
align-items: center;
}
.track-duration {
font-size: 13px;
color: var(--text-muted);
font-family: var(--font-sans);
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;
}
.playlists-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(162px, 1fr));
gap: 24px;
}
.playlists-section {
margin-top: 32px;
}
.playlists-preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(162px, 1fr));
gap: 24px;
max-width: calc(162px * 6 + 24px * 5);
}
.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;
width: 160px;
height: 220px;
}
.artist-card:hover {
background-color: var(--bg-tertiary);
}
.artist-image-wrapper {
position: relative;
width: 120px;
height: 120px;
min-height: 120px;
border-radius: 50%;
margin-bottom: 12px;
flex-shrink: 0;
overflow: hidden;
}
.artist-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
z-index: 1;
transition: opacity 0.15s ease-in;
}
.artist-image-placeholder {
width: 100%;
height: 100%;
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);
flex-shrink: 0;
}
.artist-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.3;
}
.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: color 150ms ease, background-color 150ms ease, border-color 150ms ease, opacity 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;
}
/* Unified Results View Styles */
.unified-results {
display: flex;
flex-direction: column;
gap: 32px;
}
.top-section {
display: grid;
grid-template-columns: 176px minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.most-popular {
display: flex;
flex-direction: column;
}
.most-popular-wrapper {
display: flex;
justify-content: flex-start;
width: 100%;
}
.most-popular .section-header {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
min-height: 32px;
}
.most-popular h3 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.artist-card {
width: 160px;
height: 220px;
}
.most-popular-card {
width: 160px;
height: 220px;
}
.most-popular-card .artist-image-wrapper {
width: 120px;
height: 120px;
min-height: 120px;
}
/* Popular Card (for Most Popular tracks/albums) */
.popular-card {
width: 160px;
display: flex;
flex-direction: column;
align-items: center;
padding: 6px;
background-color: var(--bg-secondary);
border-radius: 12px;
cursor: pointer;
transition: background-color 150ms ease;
}
.popular-card:hover {
background-color: var(--bg-tertiary);
}
.popular-card-artwork {
position: relative;
width: 142px;
height: 142px;
border-radius: 6px;
overflow: hidden;
background: var(--bg-tertiary);
flex-shrink: 0;
}
.popular-card-artwork img {
width: 100%;
height: 100%;
object-fit: cover;
}
.popular-card-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
color: var(--text-muted);
}
.popular-quality-badge {
margin-top: auto;
padding-top: 5px;
padding-bottom: 1px;
}
.popular-quality-badge :global(.quality-badge-compact) {
font-size: 10px;
width: auto;
color: var(--text-muted);
background: var(--alpha-10);
border: 1px solid var(--alpha-15);
border-radius: 4px;
padding: 3px 6px;
}
.popular-card-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
background: rgba(10, 10, 10, 0.75);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
opacity: 0;
transition: opacity 150ms ease;
border-radius: inherit;
}
.popular-card:hover .popular-card-overlay,
.popular-card.menu-open .popular-card-overlay {
opacity: 1;
}
.popular-overlay-genre {
padding: 10px 10px 0;
font-size: 11px;
font-weight: 600;
color: white;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
opacity: 0;
transform: translateY(8px);
max-width: 128px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.popular-card:hover .popular-overlay-genre,
.popular-card.menu-open .popular-overlay-genre {
animation: popular-slide-in-up 0.3s ease-out forwards;
}
@keyframes popular-slide-in-up {
0% {
opacity: 0;
transform: translateY(8px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.popular-card-buttons {
display: flex;
align-items: center;
gap: 10px;
opacity: 0;
transform: translateY(8px);
position: absolute;
left: 50%;
top: 60%;
transform: translate(-50%, -50%);
}
.popular-card:hover .popular-card-buttons,
.popular-card.menu-open .popular-card-buttons {
animation: popular-slide-in-down 0.35s ease-out forwards;
}
@keyframes popular-slide-in-down {
0% {
opacity: 0;
transform: translate(-50%, calc(-50% - 10px));
}
100% {
opacity: 1;
transform: translate(-50%, -50%);
}
}
.popular-overlay-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: transparent;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.85);
transition: transform 150ms ease, background-color 150ms ease, box-shadow 150ms ease;
}
.popular-overlay-btn:hover {
background-color: rgba(255, 255, 255, 0.15);
box-shadow: inset 0 0 0 1px var(--accent-primary);
}
.popular-overlay-btn--play {
width: 42px;
height: 42px;
}
/* Popular menu - uses portal pattern like TrackMenu */
.popular-menu {
position: fixed;
min-width: 160px;
z-index: 99999;
background-color: var(--bg-tertiary);
border-radius: 8px;
padding: 2px 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.popular-menu .menu-item {
width: 100%;
padding: 8px 12px;
background: none;
border: none;
color: var(--text-secondary);
text-align: left;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: background-color 150ms ease, color 150ms ease;
}
.popular-menu .menu-item span {
flex: 1;
}
.popular-menu .menu-item:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.popular-menu .separator {
height: 1px;
background-color: var(--bg-hover);
margin: 4px 0;
}
.popular-card-info {
padding: 6px 2px 0;
background: none;
border: none;
text-align: center;
cursor: pointer;
width: 100%;
}
.popular-card-info:hover .popular-card-title {
color: var(--accent-primary);
}
.popular-card-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 1px;
}
.popular-card-title.scrollable {
text-overflow: clip;
}
.popular-title-text {
display: inline-block;
white-space: nowrap;
}
.popular-card:hover .popular-card-title.scrollable .popular-title-text {
animation: popular-ticker var(--ticker-duration) linear infinite;
will-change: transform;
}
.popular-card-subtitle {
font-size: 11px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.popular-card-subtitle.scrollable {
text-overflow: clip;
}
.popular-subtitle-text {
display: inline-block;
white-space: nowrap;
}
.popular-card:hover .popular-card-subtitle.scrollable .popular-subtitle-text {
animation: popular-ticker var(--ticker-duration) linear infinite;
will-change: transform;
}
@keyframes popular-ticker {
0%, 20% { transform: translateX(0); }
70%, 80% { transform: translateX(var(--ticker-offset)); }
90%, 100% { transform: translateX(0); }
}
.artists-section h3, .section-header h3 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.carousel-controls {
display: flex;
align-items: center;
gap: 8px;
}
.carousel-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background-color: transparent;
color: var(--text-primary);
cursor: pointer;
transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease, opacity 150ms ease;
}
.carousel-btn:hover:not(:disabled) {
background-color: var(--bg-tertiary);
}
.carousel-btn:disabled {
opacity: 0.3;
cursor: default;
}
.view-all-link {
background: none;
border: none;
color: var(--accent-primary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease, opacity 150ms ease;
}
.view-all-link:hover {
background-color: var(--bg-tertiary);
text-decoration: underline;
}
.artists-carousel-wrapper {
position: relative;
overflow: hidden;
}
.artists-carousel {
display: flex;
gap: 15px;
}
.bottom-section {
display: flex;
flex-direction: column;
gap: 32px;
}
.albums-section {
width: 100%;
display: flex;
flex-direction: column;
}
.albums-carousel-wrapper {
position: relative;
overflow-x: hidden;
}
.albums-carousel {
display: flex;
gap: 16px;
}
.album-card-wrapper {
min-width: 160px;
flex-shrink: 0;
}
.view-more-card {
width: 160px;
min-width: 160px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.view-more-cover {
width: 160px;
height: 160px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg-secondary);
border: 2px solid var(--bg-tertiary);
border-radius: 8px;
cursor: pointer;
transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease, opacity 150ms ease;
}
.view-more-cover:hover {
background-color: var(--bg-tertiary);
border-color: var(--accent-primary);
}
.view-more-label {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
color: var(--text-muted);
font-size: 14px;
font-weight: 500;
}
.view-more-cover:hover .view-more-label {
color: var(--accent-primary);
}
.view-more-info {
width: 160px;
padding: 0 4px;
text-align: center;
}
.view-more-text {
font-size: 14px;
font-weight: 500;
color: var(--text-muted);
line-height: 1.3;
display: block;
}
.tracks-section {
width: 100%;
}
.tracks-list-compact {
display: flex;
flex-direction: column;
gap: 4px;
}
.track-artwork-container {
position: relative;
width: 48px;
height: 48px;
border-radius: 4px;
overflow: hidden;
}
.track-play-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
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: search-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 search-equalize {
0%, 100% {
transform: scaleY(0.5);
opacity: 0.7;
}
50% {
transform: scaleY(1);
opacity: 1;
}
}
@media (max-width: 1024px) {
.top-section {
grid-template-columns: 1fr;
gap: 24px;
}
.bottom-section {
gap: 24px;
}
}
</style>