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
+110
-13
No files matched your search
@@ -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)
|
|
||||||
|
// 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);
|
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'}
|
||||||
>
|
>
|
||||||
|
{#if isAdded}
|
||||||
|
<Check size={16} />
|
||||||
|
{:else if isAdding}
|
||||||
|
<div class="btn-spinner"></div>
|
||||||
|
{:else}
|
||||||
<Plus size={16} />
|
<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) {
|
||||||
|
|||||||
Reference in new issue
Block a user