feat(suggestions): improve UX with deduplication, icons, and radio collage

- Dedupe recommended tracks by normalized title (filters remasters/editions)
- Use actual QBZ logo SVG instead of text "Q"
- Add playlist icon and "Playlist" label for clarity
- Add diamond-style collage layout for Song Radio card
- Pass currentArtwork for radio collage images
- Clean up unused error-text CSS in LyricsPanel
This commit is contained in:
vicrodh committed 2026-01-31 13:59:05 -06:00
1 parent 9c35c22567
commit 1361f257f9
3 files changed
+173 -17

No files matched your search

@@ -300,6 +300,7 @@
{artistId}
artistName={artist}
trackName={trackTitle}
currentArtwork={artwork}
/>
{:else if activeTab === 'queue'}
<QueuePanel
@@ -106,10 +106,6 @@
font-size: 14px;
}
.error-text {
color: var(--alpha-40, rgba(255, 255, 255, 0.4));
}
.no-lyrics {
font-style: italic;
}
@@ -1,6 +1,6 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { Loader2, Play, Radio, AlertCircle } from 'lucide-svelte';
import { Loader2, Play, Radio, AlertCircle, ListMusic } from 'lucide-svelte';
import { t } from '$lib/i18n';
interface Playlist {
@@ -32,11 +32,12 @@
artistId?: number;
artistName?: string;
trackName?: string;
currentArtwork?: string;
onPlayPlaylist?: (playlistId: number) => void;
onPlayTrack?: (trackId: number) => void;
}
let { trackId, artistId, artistName, trackName, onPlayPlaylist, onPlayTrack }: Props = $props();
let { trackId, artistId, artistName, trackName, currentArtwork, onPlayPlaylist, onPlayTrack }: Props = $props();
// State
let artistPlaylists = $state<Playlist[]>([]);
@@ -53,6 +54,32 @@
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
/**
* Normalize track title to detect duplicates (different remasters, editions, etc.)
* "Song Title (2023 Remaster)" -> "song title"
* "Song Title [Deluxe Edition]" -> "song title"
*/
function normalizeTitle(title: string): string {
return title
.toLowerCase()
.replace(/\s*[\(\[][^\)\]]*(?:remaster|edition|version|mix|remix|live|mono|stereo|deluxe|bonus|anniversary).*?[\)\]]/gi, '')
.replace(/\s*-\s*(?:remaster|edition|version|mix|remix|live|mono|stereo|deluxe|bonus|anniversary).*$/gi, '')
.trim();
}
/**
* Dedupe tracks by normalized title, keeping the first occurrence
*/
function dedupeByTitle(tracks: Track[]): Track[] {
const seen = new Set<string>();
return tracks.filter(track => {
const normalized = normalizeTitle(track.title);
if (seen.has(normalized)) return false;
seen.add(normalized);
return true;
});
}
// Load data when artistId changes
$effect(() => {
if (artistId && artistId !== loadedArtistId) {
@@ -82,10 +109,10 @@
// Extract tracks for recommendations
if (artist.tracks_appears_on?.items) {
// Shuffle and take 10, excluding current track
const shuffled = artist.tracks_appears_on.items
.filter(t => t.id !== trackId)
.sort(() => Math.random() - 0.5);
// Filter current track, dedupe by title, shuffle, and take 10
const filtered = artist.tracks_appears_on.items.filter(t => t.id !== trackId);
const deduped = dedupeByTitle(filtered);
const shuffled = deduped.sort(() => Math.random() - 0.5);
recommendedTracks = shuffled.slice(0, 10);
} else {
recommendedTracks = [];
@@ -100,6 +127,19 @@
}
}
// Get unique artwork URLs for radio collage (from recommended tracks)
const radioCollageImages = $derived(() => {
const images: string[] = [];
if (currentArtwork) images.push(currentArtwork);
for (const track of recommendedTracks) {
if (track.album?.image?.large && !images.includes(track.album.image.large)) {
images.push(track.album.image.large);
}
if (images.length >= 4) break;
}
return images;
});
async function startSongRadio() {
if (!trackId || !artistId || loadingRadio) return;
@@ -158,11 +198,16 @@
{#if playlist.images?.[0]}
<img src={playlist.images[0]} alt="" class="card-image" />
{:else}
<div class="card-image-placeholder"></div>
<div class="card-image-placeholder">
<ListMusic size={32} />
</div>
{/if}
<div class="card-content">
<span class="card-title">{playlist.name}</span>
<span class="card-subtitle">{playlist.tracks_count} tracks</span>
<span class="card-subtitle">
<ListMusic size={10} />
Playlist · {playlist.tracks_count} tracks
</span>
</div>
<div class="card-play">
<Play size={16} fill="currentColor" />
@@ -179,18 +224,41 @@
title={$t('player.radioExperimental') || 'Song Radio - Experimental QBZ feature'}
>
<div class="card-badge qbz">
<span class="qbz-logo">Q</span>
<img src="/qbz-logo.svg" alt="QBZ" class="badge-icon qbz-icon" />
</div>
<div class="card-icon-bg">
<div class="radio-collage">
{#if loadingRadio}
<Loader2 size={32} class="spinner" />
<div class="collage-loading">
<Loader2 size={32} class="spinner" />
</div>
{:else}
<Radio size={32} />
{@const images = radioCollageImages()}
{#if images.length >= 4}
<!-- Diamond/rotated collage layout -->
<div class="collage-diamond">
<img src={images[0]} alt="" class="diamond-img top" />
<img src={images[1]} alt="" class="diamond-img left" />
<img src={images[2]} alt="" class="diamond-img right" />
<img src={images[3]} alt="" class="diamond-img bottom" />
</div>
{:else if images.length > 0}
<img src={images[0]} alt="" class="collage-single" />
<div class="collage-radio-overlay">
<Radio size={24} />
</div>
{:else}
<div class="card-icon-bg">
<Radio size={32} />
</div>
{/if}
{/if}
</div>
<div class="card-content">
<span class="card-title">{$t('player.songRadio') || 'Song Radio'}</span>
<span class="card-subtitle">{$t('player.basedOnTrack') || 'Based on this track'}</span>
<span class="card-subtitle">
<Radio size={10} />
{$t('player.basedOnTrack') || 'Based on this track'}
</span>
</div>
<div class="card-info" title={$t('player.radioExperimentalTooltip') || 'Radio is an experimental QBZ feature that generates a playlist based on the current track.'}>
<AlertCircle size={14} />
@@ -391,6 +459,9 @@
.card-subtitle {
font-size: 11px;
color: var(--alpha-50, rgba(255, 255, 255, 0.5));
display: flex;
align-items: center;
gap: 4px;
}
.card-play {
@@ -427,6 +498,94 @@
color: var(--alpha-70, rgba(255, 255, 255, 0.7));
}
/* QBZ Badge Icon */
.qbz-icon {
width: 16px;
height: 16px;
border-radius: 2px;
}
/* Radio Collage */
.radio-collage {
width: 100%;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
position: relative;
background: var(--alpha-10, rgba(255, 255, 255, 0.1));
}
.collage-loading {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--alpha-40, rgba(255, 255, 255, 0.4));
}
.collage-single {
width: 100%;
height: 100%;
object-fit: cover;
filter: brightness(0.7);
}
.collage-radio-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: white;
background: rgba(0, 0, 0, 0.3);
}
/* Diamond collage - rotated squares pattern */
.collage-diamond {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.diamond-img {
position: absolute;
width: 70%;
height: 70%;
object-fit: cover;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.diamond-img.top {
top: -10%;
left: 50%;
transform: translateX(-50%) rotate(5deg);
z-index: 4;
}
.diamond-img.left {
top: 50%;
left: -15%;
transform: translateY(-50%) rotate(-8deg);
z-index: 2;
}
.diamond-img.right {
top: 50%;
right: -15%;
transform: translateY(-50%) rotate(8deg);
z-index: 3;
}
.diamond-img.bottom {
bottom: -10%;
left: 50%;
transform: translateX(-50%) rotate(-3deg);
z-index: 1;
}
/* Tracks Section */
.tracks-section {
flex: 1;