feat(search): add overlay card UI for Most Popular tracks and albums

- New popular-card with square image, title, and subtitle
- Hover overlay with heart, play, and menu buttons
- Dropdown menu with play controls and navigation options
- Track cards show "Go to album", album cards show "Go to artist"
- Artists still use the existing circular card design
This commit is contained in:
vicrodh committed 2026-02-01 02:46:29 -06:00
1 parent f699cf0e63
commit d9af56192d
1 file changed
+295 -33
+295 -33
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { Search, Disc3, Music, Mic2, User, X, ChevronLeft, ChevronRight, Crown } from 'lucide-svelte';
import { Search, Disc3, Music, Mic2, User, X, ChevronLeft, ChevronRight, Crown, Heart, Play, MoreHorizontal, ListPlus, SkipForward } from 'lucide-svelte';
import AlbumCard from '../AlbumCard.svelte';
import ViewTransition from '../ViewTransition.svelte';
import TrackMenu from '../TrackMenu.svelte';
@@ -91,6 +91,18 @@
let failedTrackImages = $state<Set<number>>(new Set());
let failedArtistImages = $state<Set<number>>(new Set());
// Most Popular card menu state
let mostPopularMenuOpen = $state(false);
function handleClickOutsidePopularMenu(event: MouseEvent) {
if (mostPopularMenuOpen) {
const target = event.target as HTMLElement;
if (!target.closest('.popular-menu-wrapper')) {
mostPopularMenuOpen = false;
}
}
}
function handleTrackImageError(trackId: number) {
failedTrackImages = new Set([...failedTrackImages, trackId]);
}
@@ -625,6 +637,8 @@
);
</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 -->
@@ -804,32 +818,77 @@
</button>
{:else if allResults.most_popular?.type === 'albums'}
{@const album = allResults.most_popular.content}
<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); }}
/>
<div class="popular-card most-popular-card" class:menu-open={mostPopularMenuOpen}>
<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">
<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>
<div class="popular-menu-wrapper">
<button
class="popular-overlay-btn"
type="button"
onclick={(e) => { e.stopPropagation(); mostPopularMenuOpen = !mostPopularMenuOpen; }}
title="More options"
>
<MoreHorizontal size={16} />
</button>
{#if mostPopularMenuOpen}
<div class="popular-dropdown" onclick={(e) => e.stopPropagation()}>
<button class="popular-dropdown-item" onclick={() => { onAlbumPlay?.(album.id); mostPopularMenuOpen = false; }}>
<Play size={14} /> Play now
</button>
<button class="popular-dropdown-item" onclick={() => { onAlbumPlayNext?.(album.id); mostPopularMenuOpen = false; }}>
<SkipForward size={14} /> Play next
</button>
<button class="popular-dropdown-item" onclick={() => { onAlbumPlayLater?.(album.id); mostPopularMenuOpen = false; }}>
<ListPlus size={14} /> Add to queue
</button>
{#if album.artist?.id}
<button class="popular-dropdown-item" onclick={() => { onArtistClick?.(album.artist.id); mostPopularMenuOpen = false; }}>
<User size={14} /> Go to artist
</button>
{/if}
</div>
{/if}
</div>
</div>
</div>
</div>
<button class="popular-card-info" onclick={() => onAlbumClick?.(album.id)}>
<div class="popular-card-title">{album.title}</div>
<div class="popular-card-subtitle">{album.artist?.name || 'Unknown Artist'}</div>
</button>
</div>
{:else if allResults.most_popular?.type === 'tracks'}
{@const track = allResults.most_popular.content}
<button class="track-card most-popular-card" onclick={() => handleSearchTrackPlay(track, 0)}>
<div class="track-card-artwork">
<div class="popular-card most-popular-card" class:menu-open={mostPopularMenuOpen}>
<div class="popular-card-artwork">
{#if track.album?.image?.large || track.album?.image?.small}
<img
src={track.album.image.large || track.album.image.small}
@@ -837,19 +896,64 @@
loading="lazy"
/>
{:else}
<div class="track-card-placeholder">
<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>
<div class="popular-menu-wrapper">
<button
class="popular-overlay-btn"
type="button"
onclick={(e) => { e.stopPropagation(); mostPopularMenuOpen = !mostPopularMenuOpen; }}
title="More options"
>
<MoreHorizontal size={16} />
</button>
{#if mostPopularMenuOpen}
<div class="popular-dropdown" onclick={(e) => e.stopPropagation()}>
<button class="popular-dropdown-item" onclick={() => { handleSearchTrackPlay(track, 0); mostPopularMenuOpen = false; }}>
<Play size={14} /> Play now
</button>
<button class="popular-dropdown-item" onclick={() => { onTrackPlayNext?.(track); mostPopularMenuOpen = false; }}>
<SkipForward size={14} /> Play next
</button>
<button class="popular-dropdown-item" onclick={() => { onTrackPlayLater?.(track); mostPopularMenuOpen = false; }}>
<ListPlus size={14} /> Add to queue
</button>
{#if track.album?.id}
<button class="popular-dropdown-item" onclick={() => { onTrackGoToAlbum?.(track.album!.id!); mostPopularMenuOpen = false; }}>
<Disc3 size={14} /> Go to album
</button>
{/if}
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="track-card-info">
<div class="track-card-title">{track.title}</div>
<div class="track-card-artist">{track.performer?.name || 'Unknown Artist'}</div>
{#if track.album?.title}
<div class="track-card-album">{track.album.title}</div>
{/if}
<div class="popular-card-info">
<div class="popular-card-title">{track.title}</div>
<div class="popular-card-subtitle">{track.performer?.name || 'Unknown Artist'}</div>
</div>
</button>
</div>
{/if}
</div>
</div>
@@ -1944,6 +2048,164 @@
white-space: nowrap;
}
/* Popular Card (for Most Popular tracks/albums) */
.popular-card {
width: 160px;
display: flex;
flex-direction: column;
gap: 8px;
}
.popular-card-artwork {
position: relative;
width: 160px;
height: 160px;
border-radius: 8px;
overflow: hidden;
background: var(--bg-tertiary);
}
.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-card-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
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-card-buttons {
display: flex;
align-items: center;
gap: 12px;
opacity: 0;
transform: translateY(8px);
transition: opacity 200ms ease, transform 200ms ease;
}
.popular-card:hover .popular-card-buttons,
.popular-card.menu-open .popular-card-buttons {
opacity: 1;
transform: translateY(0);
}
.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-wrapper {
position: relative;
}
.popular-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 160px;
background: var(--bg-secondary);
border: 1px solid var(--bg-tertiary);
border-radius: 8px;
padding: 6px 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 100;
}
.popular-dropdown-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 14px;
background: none;
border: none;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
text-align: left;
transition: background-color 150ms ease;
}
.popular-dropdown-item:hover {
background-color: var(--bg-tertiary);
}
.popular-card-info {
padding: 0 4px;
background: none;
border: none;
text-align: left;
cursor: pointer;
width: 100%;
}
.popular-card-info:hover .popular-card-title {
color: var(--accent-primary);
}
.popular-card-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.popular-card-subtitle {
font-size: 12px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.artists-section h3, .section-header h3 {
font-size: 16px;
font-weight: 600;