feat(search): Most Popular can now show artist, album, or track

The Most Popular result in search now intelligently determines
the best match based on query:
- Exact name match: artist > album > track
- Starts with match: artist > album > track
- Fallback: first artist, then album, then track

Added track card display for when a track is the most popular result.
This commit is contained in:
vicrodh committed 2026-02-01 01:13:06 -06:00
1 parent d5cbd32fce
commit aa16a9276b
1 file changed
+202 -46
+202 -46
View File
@@ -220,6 +220,56 @@
let hasMoreTracks = $derived(trackResults ? trackResults.offset + trackResults.items.length < trackResults.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 hasMoreArtists = $derived(artistResults ? artistResults.offset + artistResults.items.length < artistResults.total : false);
// Determine the "Most Popular" result based on query matching
// Priority: exact match > starts with > contains > default to first artist
type MostPopularType = 'artist' | 'album' | 'track';
interface MostPopularResult {
type: MostPopularType;
artist?: Artist;
album?: Album;
track?: Track;
}
let mostPopularResult = $derived.by((): MostPopularResult | null => {
if (!allResults) return null;
const q = query.toLowerCase().trim();
if (!q) return null;
const firstArtist = allResults.artists.items[0];
const firstAlbum = allResults.albums.items[0];
const firstTrack = allResults.tracks.items[0];
// Check for exact matches first
if (firstArtist && firstArtist.name.toLowerCase() === q) {
return { type: 'artist', artist: firstArtist };
}
if (firstAlbum && firstAlbum.title.toLowerCase() === q) {
return { type: 'album', album: firstAlbum };
}
if (firstTrack && firstTrack.title.toLowerCase() === q) {
return { type: 'track', track: firstTrack };
}
// Check for "starts with" matches
if (firstArtist && firstArtist.name.toLowerCase().startsWith(q)) {
return { type: 'artist', artist: firstArtist };
}
if (firstAlbum && firstAlbum.title.toLowerCase().startsWith(q)) {
return { type: 'album', album: firstAlbum };
}
if (firstTrack && firstTrack.title.toLowerCase().startsWith(q)) {
return { type: 'track', track: firstTrack };
}
// Default: return artist if available, then album, then track
if (firstArtist) return { type: 'artist', artist: firstArtist };
if (firstAlbum) return { type: 'album', album: firstAlbum };
if (firstTrack) return { type: 'track', track: firstTrack };
return null;
});
function debounceSearch() { function debounceSearch() {
if (searchTimeout) clearTimeout(searchTimeout); if (searchTimeout) clearTimeout(searchTimeout);
// Reset filter when user types a new search // Reset filter when user types a new search
@@ -733,54 +783,78 @@
<h3><Crown size={18} color="gold" /> Most Popular</h3> <h3><Crown size={18} color="gold" /> Most Popular</h3>
</div> </div>
<div class="most-popular-wrapper"> <div class="most-popular-wrapper">
{#if allResults.artists.items.length > 0} {#if mostPopularResult?.type === 'artist' && mostPopularResult.artist}
<button class="artist-card most-popular-card" onclick={() => onArtistClick?.(allResults.artists.items[0].id)}> {@const artist = mostPopularResult.artist}
<div class="artist-image-wrapper"> <button class="artist-card most-popular-card" onclick={() => onArtistClick?.(artist.id)}>
<!-- Placeholder always visible as background --> <div class="artist-image-wrapper">
<div class="artist-image-placeholder"> <div class="artist-image-placeholder">
<User size={40} /> <User size={40} />
</div>
{#if !failedArtistImages.has(artist.id) && getArtistImage(artist)}
<img
src={getArtistImage(artist)}
alt={artist.name}
class="artist-image"
loading="lazy"
decoding="async"
onerror={() => handleArtistImageError(artist.id)}
/>
{/if}
</div> </div>
<!-- Image overlays placeholder when loaded --> <div class="artist-name">{artist.name}</div>
{#if !failedArtistImages.has(allResults.artists.items[0].id) && getArtistImage(allResults.artists.items[0])} {#if artist.albums_count}
<img <div class="artist-albums">{$t('library.albumCount', { values: { count: artist.albums_count } })}</div>
src={getArtistImage(allResults.artists.items[0])}
alt={allResults.artists.items[0].name}
class="artist-image"
loading="lazy"
decoding="async"
onerror={() => handleArtistImageError(allResults.artists.items[0].id)}
/>
{/if} {/if}
</div> </button>
<div class="artist-name">{allResults.artists.items[0].name}</div> {:else if mostPopularResult?.type === 'album' && mostPopularResult.album}
{#if allResults.artists.items[0].albums_count} {@const album = mostPopularResult.album}
<div class="artist-albums">{$t('library.albumCount', { values: { count: allResults.artists.items[0].albums_count } })}</div> <AlbumCard
{/if} albumId={album.id}
</button> artwork={getAlbumArtwork(album)}
{:else if allResults.albums.items.length > 0} title={album.title}
<AlbumCard artist={album.artist?.name || 'Unknown Artist'}
albumId={allResults.albums.items[0].id} genre={getGenreLabel(album)}
artwork={getAlbumArtwork(allResults.albums.items[0])} releaseDate={album.release_date_original}
title={allResults.albums.items[0].title} size="large"
artist={allResults.albums.items[0].artist?.name || 'Unknown Artist'} quality={getQualityLabel(album)}
genre={getGenreLabel(allResults.albums.items[0])} onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
releaseDate={allResults.albums.items[0].release_date_original} onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
size="large" onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
quality={getQualityLabel(allResults.albums.items[0])} onAddAlbumToPlaylist={onAddAlbumToPlaylist ? () => onAddAlbumToPlaylist(album.id) : undefined}
onPlay={onAlbumPlay ? () => onAlbumPlay(allResults.albums.items[0].id) : undefined} onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(allResults.albums.items[0].id) : undefined} onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(allResults.albums.items[0].id) : undefined} onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
onAddAlbumToPlaylist={onAddAlbumToPlaylist ? () => onAddAlbumToPlaylist(album.id) : undefined} isAlbumFullyDownloaded={isAlbumDownloaded(album.id)}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(allResults.albums.items[0].id) : undefined} onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(allResults.albums.items[0].id) : undefined} onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(allResults.albums.items[0].id) : undefined} {downloadStateVersion}
isAlbumFullyDownloaded={isAlbumDownloaded(allResults.albums.items[0].id)} onclick={() => { onAlbumClick?.(album.id); loadAlbumDownloadStatus(album.id); }}
onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(allResults.albums.items[0].id) : undefined} />
onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(allResults.albums.items[0].id) : undefined} {:else if mostPopularResult?.type === 'track' && mostPopularResult.track}
{downloadStateVersion} {@const track = mostPopularResult.track}
onclick={() => { onAlbumClick?.(allResults.albums.items[0].id); loadAlbumDownloadStatus(allResults.albums.items[0].id); }} <button class="track-card most-popular-card" onclick={() => handleSearchTrackPlay(track, 0)}>
/> <div class="track-card-artwork">
{/if} {#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="track-card-placeholder">
<Music size={32} />
</div>
{/if}
</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>
</button>
{/if}
</div> </div>
</div> </div>
@@ -1792,6 +1866,88 @@
min-height: 120px; min-height: 120px;
} }
/* Track card for Most Popular */
.track-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px;
background: var(--bg-secondary);
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 150ms ease;
text-align: center;
}
.track-card:hover {
background: var(--bg-tertiary);
}
.track-card.most-popular-card {
width: 160px;
height: 220px;
justify-content: flex-start;
}
.track-card-artwork {
width: 120px;
height: 120px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.track-card-artwork img {
width: 100%;
height: 100%;
object-fit: cover;
}
.track-card-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-tertiary);
color: var(--text-muted);
}
.track-card-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
width: 100%;
}
.track-card-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-card-artist {
font-size: 12px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-card-album {
font-size: 11px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.artists-section h3, .section-header h3 { .artists-section h3, .section-header h3 {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;