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">
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 {
type SuggestedTrack,
type SuggestionResult,
@@ -18,7 +20,8 @@
excludeTrackIds: number[];
/** Existing tracks in playlist for title+artist deduplication */
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;
onGoToArtist?: (artistId: number) => 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) {
if (!onAddTrack) return;
if (!onAddTrack || addingTrackIds.has(track.track_id)) return;
// Mark as adding
addingTrackIds = new Set([...addingTrackIds, track.track_id]);
try {
await onAddTrack(track.track_id);
// Remove from pool locally (will be excluded on next load anyway)
pool = pool.filter(t => t.track_id !== track.track_id);
await onAddTrack(track);
// 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
completedCycles = 0;
lastAddedAt = pool.length;
} catch (err) {
console.error('Failed to add track:', err);
addingTrackIds = new Set([...addingTrackIds].filter(id => id !== track.track_id));
}
}
function handleDismiss(track: SuggestedTrack) {
dismissTrack(playlistId, track.track_id);
// Force reactivity by reassigning pool
pool = [...pool];
// Remove from pool immediately (triggers exit animation)
pool = pool.filter(t => t.track_id !== track.track_id);
}
</script>
@@ -350,7 +370,16 @@
{:else}
<div class="suggestions-list">
{#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">
{#if track.album_image_url}
<img
@@ -417,15 +446,25 @@
</button>
<button
class="action-btn add"
class:adding={isAdding}
class:added={isAdded}
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
class="action-btn dismiss"
onclick={() => handleDismiss(track)}
title="Not interested"
disabled={isAdding || isAdded}
>
<X size={16} />
</button>
@@ -723,4 +762,37 @@
border-radius: 50%;
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>
@@ -1188,14 +1188,39 @@
}
// Add a suggested track to the playlist
async function handleAddSuggestedTrack(trackId: number) {
async function handleAddSuggestedTrack(suggestedTrack: import('$lib/services/playlistSuggestionsService').SuggestedTrack) {
try {
// Add to Qobuz playlist
await invoke('add_tracks_to_playlist', {
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();
onPlaylistUpdated?.();
} catch (err) {