Implement FavoritesView component
- Create FavoritesView with tabs for tracks, albums, and artists - Fetch favorites from backend API - Support Play All and Add to Queue for tracks - Display albums in grid with AlbumCard - Display artists with circular avatars - Wire up in +page.svelte
This commit is contained in:
1 parent
5debf9c327
commit
ddbedf6ab0
2 files changed
+544
-5
No files matched your search
@@ -0,0 +1,539 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { onMount } from 'svelte';
|
||||
import { Heart, Play, Plus, Disc3, Mic2, Music } from 'lucide-svelte';
|
||||
import AlbumCard from '../AlbumCard.svelte';
|
||||
import TrackRow from '../TrackRow.svelte';
|
||||
|
||||
interface FavoriteAlbum {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: { id: number; name: string };
|
||||
image: { small?: string; thumbnail?: string; large?: string };
|
||||
release_date_original?: string;
|
||||
hires: boolean;
|
||||
}
|
||||
|
||||
interface FavoriteTrack {
|
||||
id: number;
|
||||
title: string;
|
||||
duration: number;
|
||||
track_number: number;
|
||||
performer?: { name: string };
|
||||
album?: { id: string; title: string; image: { small?: string; thumbnail?: string; large?: string } };
|
||||
hires: boolean;
|
||||
maximum_bit_depth?: number;
|
||||
maximum_sampling_rate?: number;
|
||||
}
|
||||
|
||||
interface FavoriteArtist {
|
||||
id: number;
|
||||
name: string;
|
||||
image?: { small?: string; thumbnail?: string; large?: string };
|
||||
albums_count?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onAlbumClick?: (albumId: string) => void;
|
||||
onTrackPlay?: (track: DisplayTrack) => void;
|
||||
}
|
||||
|
||||
interface DisplayTrack {
|
||||
id: number;
|
||||
number: number;
|
||||
title: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
albumArt?: string;
|
||||
duration: string;
|
||||
durationSeconds: number;
|
||||
hires?: boolean;
|
||||
bitDepth?: number;
|
||||
samplingRate?: number;
|
||||
}
|
||||
|
||||
let { onAlbumClick, onTrackPlay }: Props = $props();
|
||||
|
||||
type TabType = 'tracks' | 'albums' | 'artists';
|
||||
let activeTab = $state<TabType>('tracks');
|
||||
|
||||
let favoriteAlbums = $state<FavoriteAlbum[]>([]);
|
||||
let favoriteTracks = $state<FavoriteTrack[]>([]);
|
||||
let favoriteArtists = $state<FavoriteArtist[]>([]);
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
loadFavorites('tracks');
|
||||
});
|
||||
|
||||
async function loadFavorites(type: string) {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const result = await invoke<any>('get_favorites', {
|
||||
favType: type,
|
||||
limit: 50,
|
||||
offset: 0
|
||||
});
|
||||
|
||||
if (type === 'tracks' && result.tracks?.items) {
|
||||
favoriteTracks = result.tracks.items;
|
||||
} else if (type === 'albums' && result.albums?.items) {
|
||||
favoriteAlbums = result.albums.items;
|
||||
} else if (type === 'artists' && result.artists?.items) {
|
||||
favoriteArtists = result.artists.items;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to load ${type} favorites:`, err);
|
||||
error = String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTabChange(tab: TabType) {
|
||||
activeTab = tab;
|
||||
loadFavorites(tab);
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function handleTrackClick(track: FavoriteTrack, index: number) {
|
||||
if (onTrackPlay) {
|
||||
onTrackPlay({
|
||||
id: track.id,
|
||||
number: index + 1,
|
||||
title: track.title,
|
||||
artist: track.performer?.name,
|
||||
album: track.album?.title,
|
||||
albumArt: track.album?.image?.thumbnail || track.album?.image?.small,
|
||||
duration: formatDuration(track.duration),
|
||||
durationSeconds: track.duration,
|
||||
hires: track.hires,
|
||||
bitDepth: track.maximum_bit_depth,
|
||||
samplingRate: track.maximum_sampling_rate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePlayAllTracks() {
|
||||
if (favoriteTracks.length === 0 || !onTrackPlay) return;
|
||||
|
||||
const queueTracks = favoriteTracks.map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.performer?.name || 'Unknown Artist',
|
||||
album: t.album?.title || 'Favorites',
|
||||
duration_secs: t.duration,
|
||||
artwork_url: t.album?.image?.thumbnail || t.album?.image?.small || '',
|
||||
}));
|
||||
|
||||
try {
|
||||
await invoke('set_queue', { tracks: queueTracks, startIndex: 0 });
|
||||
handleTrackClick(favoriteTracks[0], 0);
|
||||
} catch (err) {
|
||||
console.error('Failed to set queue:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddAllToQueue() {
|
||||
if (favoriteTracks.length === 0) return;
|
||||
|
||||
const queueTracks = favoriteTracks.map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.performer?.name || 'Unknown Artist',
|
||||
album: t.album?.title || 'Favorites',
|
||||
duration_secs: t.duration,
|
||||
artwork_url: t.album?.image?.thumbnail || t.album?.image?.small || '',
|
||||
}));
|
||||
|
||||
try {
|
||||
await invoke('add_tracks_to_queue', { tracks: queueTracks });
|
||||
} catch (err) {
|
||||
console.error('Failed to add to queue:', err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="favorites-view">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="header-icon">
|
||||
<Heart size={32} fill="var(--accent-primary)" color="var(--accent-primary)" />
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h1>Favorites</h1>
|
||||
<p class="subtitle">Your liked tracks, albums, and artists</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'tracks'}
|
||||
onclick={() => handleTabChange('tracks')}
|
||||
>
|
||||
<Music size={16} />
|
||||
<span>Tracks</span>
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'albums'}
|
||||
onclick={() => handleTabChange('albums')}
|
||||
>
|
||||
<Disc3 size={16} />
|
||||
<span>Albums</span>
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'artists'}
|
||||
onclick={() => handleTabChange('artists')}
|
||||
>
|
||||
<Mic2 size={16} />
|
||||
<span>Artists</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Actions (for tracks tab) -->
|
||||
{#if activeTab === 'tracks' && favoriteTracks.length > 0}
|
||||
<div class="actions">
|
||||
<button class="play-btn" onclick={handlePlayAllTracks}>
|
||||
<Play size={16} fill="white" />
|
||||
<span>Play All</span>
|
||||
</button>
|
||||
<button class="add-btn" onclick={handleAddAllToQueue}>
|
||||
<Plus size={16} />
|
||||
<span>Add to Queue</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
{#if loading}
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading favorites...</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error">
|
||||
<p>Failed to load favorites</p>
|
||||
<p class="error-detail">{error}</p>
|
||||
<button class="retry-btn" onclick={() => loadFavorites(activeTab)}>Retry</button>
|
||||
</div>
|
||||
{:else if activeTab === 'tracks'}
|
||||
{#if favoriteTracks.length === 0}
|
||||
<div class="empty">
|
||||
<Music size={48} />
|
||||
<p>No favorite tracks yet</p>
|
||||
<p class="empty-hint">Like tracks to see them here</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="track-list">
|
||||
{#each favoriteTracks as track, index (track.id)}
|
||||
<TrackRow
|
||||
number={index + 1}
|
||||
title={track.title}
|
||||
artist={track.performer?.name}
|
||||
duration={formatDuration(track.duration)}
|
||||
hires={track.hires}
|
||||
bitDepth={track.maximum_bit_depth}
|
||||
samplingRate={track.maximum_sampling_rate}
|
||||
onclick={() => handleTrackClick(track, index)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if activeTab === 'albums'}
|
||||
{#if favoriteAlbums.length === 0}
|
||||
<div class="empty">
|
||||
<Disc3 size={48} />
|
||||
<p>No favorite albums yet</p>
|
||||
<p class="empty-hint">Like albums to see them here</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="album-grid">
|
||||
{#each favoriteAlbums as album (album.id)}
|
||||
<AlbumCard
|
||||
artwork={album.image?.large || album.image?.thumbnail || ''}
|
||||
title={album.title}
|
||||
artist={album.artist.name}
|
||||
year={album.release_date_original?.slice(0, 4)}
|
||||
hires={album.hires}
|
||||
onclick={() => onAlbumClick?.(album.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if activeTab === 'artists'}
|
||||
{#if favoriteArtists.length === 0}
|
||||
<div class="empty">
|
||||
<Mic2 size={48} />
|
||||
<p>No favorite artists yet</p>
|
||||
<p class="empty-hint">Like artists to see them here</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="artist-grid">
|
||||
{#each favoriteArtists as artist (artist.id)}
|
||||
<div class="artist-card">
|
||||
<div class="artist-image">
|
||||
{#if artist.image?.large || artist.image?.thumbnail}
|
||||
<img src={artist.image?.large || artist.image?.thumbnail} alt={artist.name} />
|
||||
{:else}
|
||||
<div class="artist-placeholder">
|
||||
<Mic2 size={32} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="artist-name">{artist.name}</div>
|
||||
{#if artist.albums_count}
|
||||
<div class="artist-albums">{artist.albums_count} albums</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.favorites-view {
|
||||
padding: 24px;
|
||||
padding-bottom: 100px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--accent-primary) 0%, #ff6b9d 100%);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 24px;
|
||||
background-color: var(--accent-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 24px;
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--text-muted);
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
border-color: var(--text-primary);
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--bg-tertiary);
|
||||
border-top-color: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-detail {
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
margin-top: 16px;
|
||||
padding: 8px 24px;
|
||||
background-color: var(--accent-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.track-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.album-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.artist-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.artist-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.artist-card:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.artist-image {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.artist-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.artist-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.artist-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.artist-albums {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@
|
||||
import SettingsView from '$lib/components/views/SettingsView.svelte';
|
||||
import AlbumDetailView from '$lib/components/views/AlbumDetailView.svelte';
|
||||
import PlaylistDetailView from '$lib/components/views/PlaylistDetailView.svelte';
|
||||
import FavoritesView from '$lib/components/views/FavoritesView.svelte';
|
||||
|
||||
// Overlays
|
||||
import QueuePanel from '$lib/components/QueuePanel.svelte';
|
||||
@@ -868,11 +869,10 @@
|
||||
onTrackPlay={handlePlaylistTrackPlay}
|
||||
/>
|
||||
{:else if activeView === 'favorites'}
|
||||
<div class="placeholder-view">
|
||||
<h1>Favorites</h1>
|
||||
<p>Your favorite tracks and albums will appear here...</p>
|
||||
<button class="back-btn" onclick={goBack}>Go Back</button>
|
||||
</div>
|
||||
<FavoritesView
|
||||
onAlbumClick={handleAlbumClick}
|
||||
onTrackPlay={handlePlaylistTrackPlay}
|
||||
/>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
|
||||
Reference in new issue
Block a user