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:
1 parent
9c35c22567
commit
1361f257f9
3 files changed
+173
-17
No files matched your search
@@ -300,6 +300,7 @@
|
|||||||
{artistId}
|
{artistId}
|
||||||
artistName={artist}
|
artistName={artist}
|
||||||
trackName={trackTitle}
|
trackName={trackTitle}
|
||||||
|
currentArtwork={artwork}
|
||||||
/>
|
/>
|
||||||
{:else if activeTab === 'queue'}
|
{:else if activeTab === 'queue'}
|
||||||
<QueuePanel
|
<QueuePanel
|
||||||
|
|||||||
@@ -106,10 +106,6 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-text {
|
|
||||||
color: var(--alpha-40, rgba(255, 255, 255, 0.4));
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-lyrics {
|
.no-lyrics {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
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';
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
interface Playlist {
|
interface Playlist {
|
||||||
@@ -32,11 +32,12 @@
|
|||||||
artistId?: number;
|
artistId?: number;
|
||||||
artistName?: string;
|
artistName?: string;
|
||||||
trackName?: string;
|
trackName?: string;
|
||||||
|
currentArtwork?: string;
|
||||||
onPlayPlaylist?: (playlistId: number) => void;
|
onPlayPlaylist?: (playlistId: number) => void;
|
||||||
onPlayTrack?: (trackId: 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
|
// State
|
||||||
let artistPlaylists = $state<Playlist[]>([]);
|
let artistPlaylists = $state<Playlist[]>([]);
|
||||||
@@ -53,6 +54,32 @@
|
|||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
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
|
// Load data when artistId changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (artistId && artistId !== loadedArtistId) {
|
if (artistId && artistId !== loadedArtistId) {
|
||||||
@@ -82,10 +109,10 @@
|
|||||||
|
|
||||||
// Extract tracks for recommendations
|
// Extract tracks for recommendations
|
||||||
if (artist.tracks_appears_on?.items) {
|
if (artist.tracks_appears_on?.items) {
|
||||||
// Shuffle and take 10, excluding current track
|
// Filter current track, dedupe by title, shuffle, and take 10
|
||||||
const shuffled = artist.tracks_appears_on.items
|
const filtered = artist.tracks_appears_on.items.filter(t => t.id !== trackId);
|
||||||
.filter(t => t.id !== trackId)
|
const deduped = dedupeByTitle(filtered);
|
||||||
.sort(() => Math.random() - 0.5);
|
const shuffled = deduped.sort(() => Math.random() - 0.5);
|
||||||
recommendedTracks = shuffled.slice(0, 10);
|
recommendedTracks = shuffled.slice(0, 10);
|
||||||
} else {
|
} else {
|
||||||
recommendedTracks = [];
|
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() {
|
async function startSongRadio() {
|
||||||
if (!trackId || !artistId || loadingRadio) return;
|
if (!trackId || !artistId || loadingRadio) return;
|
||||||
|
|
||||||
@@ -158,11 +198,16 @@
|
|||||||
{#if playlist.images?.[0]}
|
{#if playlist.images?.[0]}
|
||||||
<img src={playlist.images[0]} alt="" class="card-image" />
|
<img src={playlist.images[0]} alt="" class="card-image" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="card-image-placeholder"></div>
|
<div class="card-image-placeholder">
|
||||||
|
<ListMusic size={32} />
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<span class="card-title">{playlist.name}</span>
|
<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>
|
||||||
<div class="card-play">
|
<div class="card-play">
|
||||||
<Play size={16} fill="currentColor" />
|
<Play size={16} fill="currentColor" />
|
||||||
@@ -179,18 +224,41 @@
|
|||||||
title={$t('player.radioExperimental') || 'Song Radio - Experimental QBZ feature'}
|
title={$t('player.radioExperimental') || 'Song Radio - Experimental QBZ feature'}
|
||||||
>
|
>
|
||||||
<div class="card-badge qbz">
|
<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>
|
||||||
<div class="card-icon-bg">
|
<div class="radio-collage">
|
||||||
{#if loadingRadio}
|
{#if loadingRadio}
|
||||||
<Loader2 size={32} class="spinner" />
|
<div class="collage-loading">
|
||||||
|
<Loader2 size={32} class="spinner" />
|
||||||
|
</div>
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<span class="card-title">{$t('player.songRadio') || 'Song Radio'}</span>
|
<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>
|
||||||
<div class="card-info" title={$t('player.radioExperimentalTooltip') || 'Radio is an experimental QBZ feature that generates a playlist based on the current track.'}>
|
<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} />
|
<AlertCircle size={14} />
|
||||||
@@ -391,6 +459,9 @@
|
|||||||
.card-subtitle {
|
.card-subtitle {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--alpha-50, rgba(255, 255, 255, 0.5));
|
color: var(--alpha-50, rgba(255, 255, 255, 0.5));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-play {
|
.card-play {
|
||||||
@@ -427,6 +498,94 @@
|
|||||||
color: var(--alpha-70, rgba(255, 255, 255, 0.7));
|
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 */
|
||||||
.tracks-section {
|
.tracks-section {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
Reference in new issue
Block a user