feat(suggestions): smooth animations for add/dismiss tracks

- Add fade/slide transitions when tracks are added or dismissed
- Show success state (checkmark + green highlight) before fade out
- Add track to local state without page reload
- Update sidebar counts via callbacks
- Disable buttons during adding animation
This commit is contained in:
vicrodh committed 2026-01-28 21:28:26 -06:00
1 parent 95fcb4ed4e
commit f1b0b85119
2 files changed
+112 -15

No files matched your search

+83 -11
View File
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { RefreshCw, Plus, X, Info, Sparkles, Play } from 'lucide-svelte'; import { RefreshCw, Plus, X, Info, Sparkles, Play, Check } from 'lucide-svelte';
import { fade, slide } from 'svelte/transition';
import { flip } from 'svelte/animate';
import { import {
type SuggestedTrack, type SuggestedTrack,
type SuggestionResult, type SuggestionResult,
@@ -18,7 +20,8 @@
excludeTrackIds: number[]; excludeTrackIds: number[];
/** Existing tracks in playlist for title+artist deduplication */ /** Existing tracks in playlist for title+artist deduplication */
existingTracks?: Array<{ title: string; artist?: string }>; existingTracks?: Array<{ title: string; artist?: string }>;
onAddTrack?: (trackId: number) => Promise<void>; /** Callback when track is added - receives full track data for local state update */
onAddTrack?: (track: SuggestedTrack) => Promise<void>;
onGoToAlbum?: (albumId: string) => void; onGoToAlbum?: (albumId: string) => void;
onGoToArtist?: (artistId: number) => void; onGoToArtist?: (artistId: number) => void;
onPreviewTrack?: (track: SuggestedTrack) => void; onPreviewTrack?: (track: SuggestedTrack) => void;
@@ -283,25 +286,42 @@
} }
} }
// Track which tracks are being added (for success animation)
let addingTrackIds = $state<Set<number>>(new Set());
let addedTrackIds = $state<Set<number>>(new Set());
async function handleAddTrack(track: SuggestedTrack) { async function handleAddTrack(track: SuggestedTrack) {
if (!onAddTrack) return; if (!onAddTrack || addingTrackIds.has(track.track_id)) return;
// Mark as adding
addingTrackIds = new Set([...addingTrackIds, track.track_id]);
try { try {
await onAddTrack(track.track_id); await onAddTrack(track);
// Remove from pool locally (will be excluded on next load anyway)
pool = pool.filter(t => t.track_id !== track.track_id); // Show success state briefly
addedTrackIds = new Set([...addedTrackIds, track.track_id]);
addingTrackIds = new Set([...addingTrackIds].filter(id => id !== track.track_id));
// After a short delay, remove from pool (triggers exit animation)
setTimeout(() => {
pool = pool.filter(t => t.track_id !== track.track_id);
addedTrackIds = new Set([...addedTrackIds].filter(id => id !== track.track_id));
}, 600);
// Reset cycle counter - user found something they liked // Reset cycle counter - user found something they liked
completedCycles = 0; completedCycles = 0;
lastAddedAt = pool.length; lastAddedAt = pool.length;
} catch (err) { } catch (err) {
console.error('Failed to add track:', err); console.error('Failed to add track:', err);
addingTrackIds = new Set([...addingTrackIds].filter(id => id !== track.track_id));
} }
} }
function handleDismiss(track: SuggestedTrack) { function handleDismiss(track: SuggestedTrack) {
dismissTrack(playlistId, track.track_id); dismissTrack(playlistId, track.track_id);
// Force reactivity by reassigning pool // Remove from pool immediately (triggers exit animation)
pool = [...pool]; pool = pool.filter(t => t.track_id !== track.track_id);
} }
</script> </script>
@@ -350,7 +370,16 @@
{:else} {:else}
<div class="suggestions-list"> <div class="suggestions-list">
{#each visibleTracks as track (track.track_id)} {#each visibleTracks as track (track.track_id)}
<div class="suggestion-row"> {@const isAdding = addingTrackIds.has(track.track_id)}
{@const isAdded = addedTrackIds.has(track.track_id)}
<div
class="suggestion-row"
class:adding={isAdding}
class:added={isAdded}
in:fade={{ duration: 200, delay: 50 }}
out:slide={{ duration: 250 }}
animate:flip={{ duration: 200 }}
>
<div class="album-art"> <div class="album-art">
{#if track.album_image_url} {#if track.album_image_url}
<img <img
@@ -417,15 +446,25 @@
</button> </button>
<button <button
class="action-btn add" class="action-btn add"
class:adding={isAdding}
class:added={isAdded}
onclick={() => handleAddTrack(track)} onclick={() => handleAddTrack(track)}
title="Add to playlist" disabled={isAdding || isAdded}
title={isAdded ? 'Added!' : isAdding ? 'Adding...' : 'Add to playlist'}
> >
<Plus size={16} /> {#if isAdded}
<Check size={16} />
{:else if isAdding}
<div class="btn-spinner"></div>
{:else}
<Plus size={16} />
{/if}
</button> </button>
<button <button
class="action-btn dismiss" class="action-btn dismiss"
onclick={() => handleDismiss(track)} onclick={() => handleDismiss(track)}
title="Not interested" title="Not interested"
disabled={isAdding || isAdded}
> >
<X size={16} /> <X size={16} />
</button> </button>
@@ -723,4 +762,37 @@
border-radius: 50%; border-radius: 50%;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
/* Adding/Added states */
.suggestion-row.adding {
opacity: 0.7;
pointer-events: none;
}
.suggestion-row.added {
background: linear-gradient(90deg, rgba(34, 197, 94, 0.15) 0%, transparent 100%);
}
.suggestion-row.added .actions {
opacity: 1;
}
.action-btn.add.adding,
.action-btn.add.added {
pointer-events: none;
}
.action-btn.add.added {
background: var(--accent-primary);
color: white;
}
.btn-spinner {
width: 14px;
height: 14px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
</style> </style>
@@ -1188,14 +1188,39 @@
} }
// Add a suggested track to the playlist // Add a suggested track to the playlist
async function handleAddSuggestedTrack(trackId: number) { async function handleAddSuggestedTrack(suggestedTrack: import('$lib/services/playlistSuggestionsService').SuggestedTrack) {
try { try {
// Add to Qobuz playlist
await invoke('add_tracks_to_playlist', { await invoke('add_tracks_to_playlist', {
playlistId, playlistId,
trackIds: [trackId] trackIds: [suggestedTrack.track_id]
}); });
// Reload to show the new track
await loadPlaylist(); // Add to local tracks array immediately (no reload needed)
const newTrack: DisplayTrack = {
id: suggestedTrack.track_id,
number: tracks.length + 1,
title: suggestedTrack.title,
artist: suggestedTrack.artist_name,
artistId: suggestedTrack.artist_id,
album: suggestedTrack.album_title,
albumId: suggestedTrack.album_id,
albumArt: suggestedTrack.album_image_url,
duration: formatDuration(suggestedTrack.duration),
durationSeconds: suggestedTrack.duration,
addedIndex: tracks.length, // Latest added
};
// Append to tracks array
tracks = [...tracks, newTrack];
// Update playlist count
if (playlist) {
playlist.tracks_count = (playlist.tracks_count || 0) + 1;
playlist.duration = (playlist.duration || 0) + suggestedTrack.duration;
}
// Notify parent (sidebar count update, etc.)
notifyParentOfCounts(); notifyParentOfCounts();
onPlaylistUpdated?.(); onPlaylistUpdated?.();
} catch (err) { } catch (err) {