Files
qbz/src/lib/components/immersive/panels/SuggestionsPanel.svelte
T
vicrodh 79672c4afb fix(suggestions): use highest quality playlist artwork
Changed from last (smallest) to first (largest) image in array
2026-02-01 12:30:35 -06:00

780 lines
20 KiB
Svelte

<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { Loader2, Play, Radio, AlertCircle, ListPlus, ListEnd } from 'lucide-svelte';
import { t } from '$lib/i18n';
interface Playlist {
id: number;
name: string;
description?: string;
owner: { id: number; name: string };
images?: string[];
tracks_count: number;
}
interface Track {
id: number;
title: string;
performer?: { id: number; name: string };
album?: { id: string; title: string; image?: { large?: string } };
duration: number;
}
interface TracksContainer {
items: Track[];
total: number;
}
interface ArtistDetail {
id: number;
name: string;
playlists?: Playlist[];
tracks_appears_on?: { items: Track[] };
}
interface Props {
trackId?: number;
artistId?: number;
artistName?: string;
trackName?: string;
currentArtwork?: string;
onPlayPlaylist?: (playlistId: number) => void;
onPlayTrack?: (trackId: number) => void;
onAddToQueue?: (type: 'playlist' | 'radio', id: number) => void;
onPlayNext?: (type: 'playlist' | 'radio', id: number) => void;
}
let { trackId, artistId, artistName, trackName, currentArtwork, onPlayPlaylist, onPlayTrack, onAddToQueue, onPlayNext }: Props = $props();
// State
let artistPlaylists = $state<Playlist[]>([]);
let recommendedTracks = $state<Track[]>([]);
let loading = $state(false);
let loadingRadio = $state(false);
let error = $state<string | null>(null);
let loadedArtistId = $state<number | null>(null);
// Format duration
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
/**
* Dedupe tracks by exact lowercase title, keeping the first occurrence.
*/
function dedupeByExactTitle(tracks: Track[]): Track[] {
const seen = new Set<string>();
return tracks.filter(track => {
const key = track.title.toLowerCase().trim();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
// Load data when artistId changes
$effect(() => {
if (artistId && artistId !== loadedArtistId) {
loadSuggestions(artistId);
} else if (!artistId && loadedArtistId) {
artistPlaylists = [];
recommendedTracks = [];
loadedArtistId = null;
}
});
async function loadSuggestions(id: number) {
loading = true;
error = null;
try {
// Get artist detail which includes playlists and tracks
const artist = await invoke<ArtistDetail>('get_artist_detail', { artistId: id });
loadedArtistId = id;
// Extract curated playlists (max 2)
if (artist.playlists) {
artistPlaylists = artist.playlists.slice(0, 2);
} else {
artistPlaylists = [];
}
// Extract tracks for recommendations
let tracks: Track[] = [];
if (artist.tracks_appears_on?.items) {
const filtered = artist.tracks_appears_on.items.filter(t => t.id !== trackId);
tracks = dedupeByExactTitle(filtered);
}
// If sparse data, fallback to artist's popular tracks
if (tracks.length < 5) {
console.log(`[Suggestions] Sparse tracks_appears_on (${tracks.length}), fetching artist tracks...`);
try {
const artistTracks = await invoke<TracksContainer>('get_artist_tracks', {
artistId: id,
limit: 30,
offset: 0
});
if (artistTracks.items) {
const filtered = artistTracks.items.filter(t => t.id !== trackId);
const deduped = dedupeByExactTitle(filtered);
// Merge with existing, prioritizing new tracks
const existingIds = new Set(tracks.map(t => t.id));
for (const t of deduped) {
if (!existingIds.has(t.id)) {
tracks.push(t);
}
}
}
} catch (e) {
console.warn('[Suggestions] Failed to fetch artist tracks:', e);
}
}
// Shuffle and take 10
const shuffled = tracks.sort(() => Math.random() - 0.5);
recommendedTracks = shuffled.slice(0, 10);
} catch (e) {
console.error('Failed to load suggestions:', e);
error = 'Failed to load suggestions';
artistPlaylists = [];
recommendedTracks = [];
} finally {
loading = false;
}
}
// Get unique artwork URLs for radio collage
const radioCollageImages = $derived.by(() => {
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;
loadingRadio = true;
try {
await invoke('create_track_radio', {
trackId,
trackName: trackName || 'Unknown Track',
artistId
});
} catch (e) {
console.error('Failed to start song radio:', e);
error = 'Failed to start radio';
} finally {
loadingRadio = false;
}
}
function handlePlayPlaylist(playlistId: number) {
onPlayPlaylist?.(playlistId);
}
function handlePlayTrack(id: number) {
onPlayTrack?.(id);
}
const isLoading = $derived(loading && !artistPlaylists.length && !recommendedTracks.length);
</script>
<div class="suggestions-panel">
{#if isLoading}
<div class="loading-state">
<Loader2 size={28} class="spinner" />
</div>
{:else if !artistId}
<div class="empty-state">
<Radio size={32} strokeWidth={1.5} />
<span>{$t('player.noTrackSelected') || 'No track selected'}</span>
</div>
{:else}
<div class="suggestions-content">
<!-- Top Cards Section -->
<div class="cards-section">
<!-- Artist Playlists from Qobuz -->
{#each artistPlaylists as playlist (playlist.id)}
{@const playlistImage = playlist.images?.[0] || playlist.images?.[playlist.images.length - 1]}
<div class="card playlist-card">
<div class="card-badge qobuz">
<img src="/qobuz-logo-filled.svg" alt="Qobuz" class="badge-icon badge-qobuz" />
</div>
<div class="card-image-wrapper">
{#if playlistImage}
<img src={playlistImage} alt="" class="card-image" />
{:else}
<div class="card-image-placeholder">
<img src="/playlist.svg" alt="" class="placeholder-icon" />
</div>
{/if}
<!-- Glyph that hides on hover -->
<div class="card-glyph">
<img src="/playlist.svg" alt="" class="glyph-icon" />
</div>
<!-- Hover overlay with actions -->
<div class="card-overlay">
<button class="overlay-btn secondary" onclick={() => onAddToQueue?.('playlist', playlist.id)} title="Add to queue">
<ListPlus size={16} />
</button>
<button class="overlay-btn primary" onclick={() => handlePlayPlaylist(playlist.id)} title="Play">
<Play size={20} fill="currentColor" />
</button>
<button class="overlay-btn secondary" onclick={() => onPlayNext?.('playlist', playlist.id)} title="Play next">
<ListEnd size={16} />
</button>
</div>
</div>
<div class="card-content">
<span class="card-title">{playlist.name}</span>
<span class="card-subtitle">Playlist · {playlist.tracks_count} tracks</span>
</div>
</div>
{/each}
<!-- Song Radio Card -->
{#if trackId}
<div class="card radio-card">
<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} />
</div>
<div class="card-badge qbz">
<img src="/qbz-logo.svg" alt="QBZ" class="badge-icon badge-qbz" />
</div>
<div class="card-image-wrapper">
<div class="radio-collage">
{#if loadingRadio}
<div class="collage-loading">
<Loader2 size={32} class="spinner" />
</div>
{:else if radioCollageImages.length >= 4}
<div class="collage-diamond">
<img src={radioCollageImages[0]} alt="" class="diamond-img top" />
<img src={radioCollageImages[1]} alt="" class="diamond-img left" />
<img src={radioCollageImages[2]} alt="" class="diamond-img right" />
<img src={radioCollageImages[3]} alt="" class="diamond-img bottom" />
</div>
{:else if radioCollageImages.length > 0}
<img src={radioCollageImages[0]} alt="" class="collage-single" />
{:else}
<div class="card-image-placeholder">
<Radio size={32} />
</div>
{/if}
</div>
<!-- Glyph that hides on hover -->
<div class="card-glyph">
<Radio size={26} />
</div>
<!-- Hover overlay with play button only (radio replaces queue) -->
<div class="card-overlay">
<button class="overlay-btn primary" onclick={startSongRadio} disabled={loadingRadio} title="Play Radio">
<Play size={20} fill="currentColor" />
</button>
</div>
</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>
</div>
</div>
{/if}
</div>
<!-- Recommended Tracks Section -->
{#if recommendedTracks.length > 0}
<div class="tracks-section">
<h3 class="section-title">{$t('player.recommendedTracks') || 'Recommended'}</h3>
<div class="tracks-list">
{#each recommendedTracks as track (track.id)}
<button
class="track-item"
onclick={() => handlePlayTrack(track.id)}
>
<div class="track-artwork">
{#if track.album?.image?.large}
<img src={track.album.image.large} alt="" />
{:else}
<div class="artwork-placeholder"></div>
{/if}
<div class="track-play-overlay">
<Play size={14} fill="currentColor" />
</div>
</div>
<div class="track-info">
<span class="track-title">{track.title}</span>
<span class="track-artist">{track.performer?.name || 'Unknown'}</span>
</div>
<span class="track-duration">{formatDuration(track.duration)}</span>
</button>
{/each}
</div>
</div>
{/if}
{#if error}
<div class="error-message">{error}</div>
{/if}
</div>
{/if}
</div>
<style>
.suggestions-panel {
flex: 1;
min-height: 0;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
height: 100%;
min-height: 200px;
color: var(--alpha-50, rgba(255, 255, 255, 0.5));
font-size: 14px;
}
.loading-state :global(.spinner),
.radio-card :global(.spinner) {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.suggestions-content {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 24px;
padding-right: 8px;
}
.suggestions-content::-webkit-scrollbar {
width: 4px;
}
.suggestions-content::-webkit-scrollbar-thumb {
background: var(--alpha-20, rgba(255, 255, 255, 0.2));
border-radius: 2px;
}
/* Cards Section */
.cards-section {
display: flex;
gap: 12px;
flex-wrap: wrap;
padding-top: 4px; /* Space for hover effect */
}
.card {
flex: 1;
min-width: 140px;
max-width: 180px;
background: var(--alpha-10, rgba(255, 255, 255, 0.1));
border: 1px solid var(--alpha-10, rgba(255, 255, 255, 0.1));
border-radius: 12px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
position: relative;
text-align: left;
transition: background 150ms ease, border-color 150ms ease;
}
.card:hover {
background: var(--alpha-15, rgba(255, 255, 255, 0.15));
border-color: var(--alpha-20, rgba(255, 255, 255, 0.2));
}
.card-badge {
position: absolute;
top: 8px;
right: 8px;
width: 22px;
height: 22px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
z-index: 3;
}
.badge-icon {
object-fit: contain;
}
/* Qobuz icon: viewBox 1001x1006, Q icon with ~15% whitespace around content */
.badge-qobuz {
width: 20px;
height: 20px;
}
/* QBZ icon: viewBox 1083x1083, circular vinyl fills entire box - smaller to match Qobuz visually */
.badge-qbz {
width: 17px;
height: 17px;
}
.card-info {
position: absolute;
top: 8px;
left: 8px;
color: var(--alpha-50, rgba(255, 255, 255, 0.5));
cursor: help;
z-index: 3;
}
.card-info:hover {
color: var(--alpha-80, rgba(255, 255, 255, 0.8));
}
/* Image wrapper with hover effects */
.card-image-wrapper {
position: relative;
width: 100%;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
transition: transform 150ms ease;
}
.card:hover .card-image-wrapper {
transform: translateY(-4px);
}
.card-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.card-image-placeholder {
width: 100%;
height: 100%;
background: var(--alpha-10, rgba(255, 255, 255, 0.1));
display: flex;
align-items: center;
justify-content: center;
color: var(--alpha-40, rgba(255, 255, 255, 0.4));
}
.placeholder-icon {
width: 32px;
height: 32px;
opacity: 0.4;
filter: invert(1);
}
/* Glyph overlay (visible by default, hides on hover) */
.card-glyph {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
color: white;
transition: opacity 150ms ease;
z-index: 5;
}
/* Playlist SVG: 20x20 viewBox but content only fills ~77% - needs larger size to match Lucide Radio */
.glyph-icon {
width: 32px;
height: 32px;
filter: invert(1);
opacity: 0.9;
}
.card:hover .card-glyph {
opacity: 0;
}
/* Hover overlay with action buttons */
.card-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: rgba(0, 0, 0, 0.6);
opacity: 0;
transition: opacity 150ms ease;
z-index: 6;
}
.card:hover .card-overlay {
opacity: 1;
}
.overlay-btn {
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
cursor: pointer;
color: white;
transition: transform 100ms ease, background 100ms ease;
}
.overlay-btn:hover {
transform: scale(1.1);
}
.overlay-btn:disabled {
opacity: 0.5;
cursor: wait;
}
.overlay-btn.primary {
width: 40px;
height: 40px;
background: var(--accent-primary, #7c3aed);
}
.overlay-btn.primary:hover {
background: var(--accent-hover, #6d28d9);
}
.overlay-btn.secondary {
width: 32px;
height: 32px;
background: var(--alpha-30, rgba(255, 255, 255, 0.3));
}
.overlay-btn.secondary:hover {
background: var(--alpha-50, rgba(255, 255, 255, 0.5));
}
.card-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.card-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary, white);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-subtitle {
font-size: 11px;
color: var(--alpha-50, rgba(255, 255, 255, 0.5));
}
/* Radio Collage */
.radio-collage {
width: 100%;
height: 100%;
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;
}
/* 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;
display: flex;
flex-direction: column;
min-height: 0;
}
.section-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--alpha-50, rgba(255, 255, 255, 0.5));
margin: 0 0 12px 0;
}
.tracks-list {
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
}
.track-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
background: none;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 150ms ease;
text-align: left;
}
.track-item:hover {
background: var(--alpha-10, rgba(255, 255, 255, 0.1));
}
.track-artwork {
position: relative;
width: 40px;
height: 40px;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
}
.track-artwork img,
.artwork-placeholder {
width: 100%;
height: 100%;
object-fit: cover;
}
.artwork-placeholder {
background: var(--alpha-15, rgba(255, 255, 255, 0.15));
}
.track-play-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
color: white;
opacity: 0;
transition: opacity 150ms ease;
}
.track-item:hover .track-play-overlay {
opacity: 1;
}
.track-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.track-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary, white);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-artist {
font-size: 12px;
color: var(--alpha-60, rgba(255, 255, 255, 0.6));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-duration {
font-size: 12px;
font-family: var(--font-mono, monospace);
color: var(--alpha-50, rgba(255, 255, 255, 0.5));
flex-shrink: 0;
}
.error-message {
padding: 12px;
background: rgba(239, 68, 68, 0.1);
border-radius: 8px;
color: #ef4444;
font-size: 13px;
text-align: center;
}
</style>