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:
vicrodh committed 2026-01-09 11:00:32 -06:00
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>
+5 -5
View File
@@ -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>