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:
1 parent
95fcb4ed4e
commit
f1b0b85119
2 files changed
+112
-15
No files matched your search
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user