refactor(favorites): centralize track favorites in favoritesStore
- Create favoritesStore.ts for centralized favorites state management - Refactor TrackRow to be self-sufficient with internal favorites handling - TrackRow now subscribes to store and toggles favorites internally - Remove redundant favoriteTrackIds state from AlbumDetailView and PlaylistDetailView - Remove manual onAddFavorite prop drilling from all views - Add trackId prop to TrackRow for store lookup (optional for local tracks) - Add hideFavorite prop for local tracks that don't have Qobuz IDs - Load favorites once on app init in +page.svelte
This commit is contained in:
1 parent
48a010c597
commit
1b90a45212
8 files changed
+229
-67
No files matched your search
@@ -1,21 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Play, Heart } from 'lucide-svelte';
|
||||
import TrackMenu from './TrackMenu.svelte';
|
||||
import DownloadButton from './DownloadButton.svelte';
|
||||
import {
|
||||
subscribe as subscribeFavorites,
|
||||
isTrackFavorite,
|
||||
toggleTrackFavorite
|
||||
} from '$lib/stores/favoritesStore';
|
||||
|
||||
type DownloadStatus = 'none' | 'queued' | 'downloading' | 'ready' | 'failed';
|
||||
|
||||
interface Props {
|
||||
trackId?: number; // Optional - required for favorites functionality unless hideFavorite=true
|
||||
number: number;
|
||||
title: string;
|
||||
artist?: string;
|
||||
duration: string;
|
||||
quality?: string;
|
||||
isPlaying?: boolean;
|
||||
isFavorite?: boolean;
|
||||
isFavoriteOverride?: boolean; // Optional override for favorite state
|
||||
downloadStatus?: DownloadStatus;
|
||||
downloadProgress?: number;
|
||||
hideDownload?: boolean;
|
||||
hideFavorite?: boolean;
|
||||
onPlay?: () => void;
|
||||
onDownload?: () => void;
|
||||
onRemoveDownload?: () => void;
|
||||
@@ -26,7 +34,6 @@
|
||||
onPlayNow?: () => void;
|
||||
onPlayNext?: () => void;
|
||||
onPlayLater?: () => void;
|
||||
onAddFavorite?: () => void;
|
||||
onAddToPlaylist?: () => void;
|
||||
onShareQobuz?: () => void;
|
||||
onShareSonglink?: () => void;
|
||||
@@ -35,16 +42,18 @@
|
||||
}
|
||||
|
||||
let {
|
||||
trackId,
|
||||
number,
|
||||
title,
|
||||
artist,
|
||||
duration,
|
||||
quality,
|
||||
isPlaying = false,
|
||||
isFavorite = false,
|
||||
isFavoriteOverride,
|
||||
downloadStatus = 'none',
|
||||
downloadProgress = 0,
|
||||
hideDownload = false,
|
||||
hideFavorite = false,
|
||||
onPlay,
|
||||
onDownload,
|
||||
onRemoveDownload,
|
||||
@@ -52,7 +61,30 @@
|
||||
}: Props = $props();
|
||||
|
||||
let isHovered = $state(false);
|
||||
let favoriteFromStore = $state(false);
|
||||
|
||||
// Use override if provided, otherwise use store
|
||||
const isFavorite = $derived(isFavoriteOverride ?? favoriteFromStore);
|
||||
const playNowAction = $derived(menuActions?.onPlayNow ?? onPlay);
|
||||
|
||||
// Subscribe to favorites store (only if trackId is provided)
|
||||
onMount(() => {
|
||||
if (trackId !== undefined) {
|
||||
favoriteFromStore = isTrackFavorite(trackId);
|
||||
const unsubscribe = subscribeFavorites(() => {
|
||||
favoriteFromStore = isTrackFavorite(trackId);
|
||||
});
|
||||
return unsubscribe;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle favorite toggle internally
|
||||
async function handleToggleFavorite(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (trackId !== undefined) {
|
||||
await toggleTrackFavorite(trackId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -98,21 +130,20 @@
|
||||
{/if}
|
||||
|
||||
<!-- Favorite Button -->
|
||||
<button
|
||||
class="favorite-btn"
|
||||
class:is-favorite={isFavorite}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
menuActions?.onAddFavorite?.();
|
||||
}}
|
||||
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
{#if isFavorite}
|
||||
<Heart size={14} fill="var(--accent-primary)" color="var(--accent-primary)" />
|
||||
{:else}
|
||||
<Heart size={14} color="var(--text-muted)" />
|
||||
{/if}
|
||||
</button>
|
||||
{#if !hideFavorite}
|
||||
<button
|
||||
class="favorite-btn"
|
||||
class:is-favorite={isFavorite}
|
||||
onclick={handleToggleFavorite}
|
||||
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
{#if isFavorite}
|
||||
<Heart size={14} fill="var(--accent-primary)" color="var(--accent-primary)" />
|
||||
{:else}
|
||||
<Heart size={14} color="var(--text-muted)" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Download Indicator -->
|
||||
{#if !hideDownload}
|
||||
@@ -132,7 +163,7 @@
|
||||
onPlayNow={playNowAction}
|
||||
onPlayNext={menuActions?.onPlayNext}
|
||||
onPlayLater={menuActions?.onPlayLater}
|
||||
onAddFavorite={menuActions?.onAddFavorite}
|
||||
onAddFavorite={handleToggleFavorite}
|
||||
onAddToPlaylist={menuActions?.onAddToPlaylist}
|
||||
onShareQobuz={menuActions?.onShareQobuz}
|
||||
onShareSonglink={menuActions?.onShareSonglink}
|
||||
|
||||
@@ -89,16 +89,13 @@
|
||||
let isFavorite = $state(false);
|
||||
let isFavoriteLoading = $state(false);
|
||||
let playBtnHovered = $state(false);
|
||||
let favoriteTrackIds = $state<Set<number>>(new Set());
|
||||
|
||||
interface FavoritesResponse {
|
||||
albums?: { items: Array<{ id: string }>; total: number };
|
||||
tracks?: { items: Array<{ id: number }>; total: number };
|
||||
}
|
||||
|
||||
// Check if album and tracks are in favorites on mount
|
||||
// Check if album is in favorites on mount
|
||||
onMount(async () => {
|
||||
// Fetch album favorites
|
||||
try {
|
||||
const response = await invoke<FavoritesResponse>('get_favorites', {
|
||||
favType: 'albums',
|
||||
@@ -111,20 +108,6 @@
|
||||
} catch (err) {
|
||||
console.error('Failed to check album favorite status:', err);
|
||||
}
|
||||
|
||||
// Fetch track favorites
|
||||
try {
|
||||
const response = await invoke<FavoritesResponse>('get_favorites', {
|
||||
favType: 'tracks',
|
||||
limit: 500,
|
||||
offset: 0
|
||||
});
|
||||
if (response.tracks?.items) {
|
||||
favoriteTrackIds = new Set(response.tracks.items.map(item => item.id));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check track favorite status:', err);
|
||||
}
|
||||
});
|
||||
|
||||
async function toggleFavorite() {
|
||||
@@ -223,13 +206,13 @@
|
||||
{#each album.tracks as track (`${track.id}-${downloadStateVersion}`)}
|
||||
{@const downloadInfo = getTrackDownloadStatus?.(track.id) ?? { status: 'none' as const, progress: 0 }}
|
||||
<TrackRow
|
||||
trackId={track.id}
|
||||
number={track.number}
|
||||
title={track.title}
|
||||
artist={track.artist}
|
||||
duration={track.duration}
|
||||
quality={track.quality}
|
||||
isPlaying={currentTrack === track.id}
|
||||
isFavorite={favoriteTrackIds.has(track.id)}
|
||||
downloadStatus={downloadInfo.status}
|
||||
downloadProgress={downloadInfo.progress}
|
||||
onPlay={() => {
|
||||
@@ -245,7 +228,6 @@
|
||||
},
|
||||
onPlayNext: onTrackPlayNext ? () => onTrackPlayNext(track) : undefined,
|
||||
onPlayLater: onTrackPlayLater ? () => onTrackPlayLater(track) : undefined,
|
||||
onAddFavorite: onTrackAddFavorite ? () => onTrackAddFavorite(track.id) : undefined,
|
||||
onAddToPlaylist: onAddTrackToPlaylist ? () => onAddTrackToPlaylist(track.id) : undefined,
|
||||
onShareQobuz: onTrackShareQobuz ? () => onTrackShareQobuz(track.id) : undefined,
|
||||
onShareSonglink: onTrackShareSonglink ? () => onTrackShareSonglink(track) : undefined,
|
||||
|
||||
@@ -377,12 +377,13 @@
|
||||
{@const displayTrack = buildDisplayTrack(track, index)}
|
||||
{@const downloadInfo = getTrackDownloadStatus?.(track.id) ?? { status: 'none' as const, progress: 0 }}
|
||||
<TrackRow
|
||||
trackId={track.id}
|
||||
number={index + 1}
|
||||
title={track.title}
|
||||
artist={track.performer?.name}
|
||||
duration={formatDuration(track.duration)}
|
||||
quality={track.hires ? 'Hi-Res' : undefined}
|
||||
isFavorite={true}
|
||||
isFavoriteOverride={true}
|
||||
downloadStatus={downloadInfo.status}
|
||||
downloadProgress={downloadInfo.progress}
|
||||
onPlay={() => handleTrackClick(track, index)}
|
||||
@@ -392,7 +393,6 @@
|
||||
onPlayNow: () => handleTrackClick(track, index),
|
||||
onPlayNext: onTrackPlayNext ? () => onTrackPlayNext(displayTrack) : undefined,
|
||||
onPlayLater: onTrackPlayLater ? () => onTrackPlayLater(displayTrack) : undefined,
|
||||
onAddFavorite: onTrackAddFavorite ? () => onTrackAddFavorite(track.id) : undefined,
|
||||
onAddToPlaylist: onTrackAddToPlaylist ? () => onTrackAddToPlaylist(track.id) : undefined,
|
||||
onShareQobuz: onTrackShareQobuz ? () => onTrackShareQobuz(track.id) : undefined,
|
||||
onShareSonglink: onTrackShareSonglink ? () => onTrackShareSonglink(displayTrack) : undefined,
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import TrackRow from '../TrackRow.svelte';
|
||||
import HomeSettingsModal from '../HomeSettingsModal.svelte';
|
||||
import { formatDuration, formatQuality, getQobuzImage } from '$lib/adapters/qobuzAdapters';
|
||||
import { handleAddToFavorites } from '$lib/services/trackActions';
|
||||
import {
|
||||
subscribe as subscribeHomeSettings,
|
||||
getSettings,
|
||||
@@ -587,6 +586,7 @@
|
||||
<div class="track-list">
|
||||
{#each continueTracks as track, index}
|
||||
<TrackRow
|
||||
trackId={track.id}
|
||||
number={index + 1}
|
||||
title={track.title}
|
||||
artist={track.artist}
|
||||
@@ -595,8 +595,7 @@
|
||||
hideDownload={true}
|
||||
onPlay={() => onTrackPlay?.(track)}
|
||||
menuActions={{
|
||||
onPlayNow: () => onTrackPlay?.(track),
|
||||
onAddFavorite: () => handleAddToFavorites(track.id)
|
||||
onPlayNow: () => onTrackPlay?.(track)
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
@@ -486,6 +486,7 @@
|
||||
duration={formatDuration(track.duration_secs)}
|
||||
quality={getQualityBadge(track)}
|
||||
hideDownload={true}
|
||||
hideFavorite={true}
|
||||
onPlay={() => handleTrackPlay(track)}
|
||||
menuActions={{
|
||||
onPlayNow: () => handleTrackPlay(track),
|
||||
@@ -698,6 +699,7 @@
|
||||
duration={formatDuration(track.duration_secs)}
|
||||
quality={getQualityBadge(track)}
|
||||
hideDownload={true}
|
||||
hideFavorite={true}
|
||||
onPlay={() => handleTrackPlay(track)}
|
||||
menuActions={{
|
||||
onPlayNow: () => handleTrackPlay(track),
|
||||
|
||||
@@ -129,7 +129,6 @@
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let playBtnHovered = $state(false);
|
||||
let favoriteTrackIds = $state<Set<number>>(new Set());
|
||||
|
||||
// Local settings state
|
||||
let searchQuery = $state('');
|
||||
@@ -138,14 +137,9 @@
|
||||
let customArtworkPath = $state<string | null>(null);
|
||||
let showSortMenu = $state(false);
|
||||
|
||||
interface FavoritesResponse {
|
||||
tracks?: { items: Array<{ id: number }>; total: number };
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadPlaylist();
|
||||
loadSettings();
|
||||
loadFavorites();
|
||||
loadLocalTracks();
|
||||
});
|
||||
|
||||
@@ -160,21 +154,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFavorites() {
|
||||
try {
|
||||
const response = await invoke<FavoritesResponse>('get_favorites', {
|
||||
favType: 'tracks',
|
||||
limit: 500,
|
||||
offset: 0
|
||||
});
|
||||
if (response.tracks?.items) {
|
||||
favoriteTrackIds = new Set(response.tracks.items.map(item => item.id));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load favorite tracks:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlaylist() {
|
||||
loading = true;
|
||||
error = null;
|
||||
@@ -659,12 +638,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
<TrackRow
|
||||
trackId={track.isLocal ? undefined : track.id}
|
||||
number={idx + 1}
|
||||
title={track.title}
|
||||
artist={track.artist}
|
||||
duration={track.duration}
|
||||
quality={track.hires ? 'Hi-Res' : undefined}
|
||||
isFavorite={!track.isLocal && favoriteTrackIds.has(track.id)}
|
||||
hideFavorite={track.isLocal}
|
||||
hideDownload={track.isLocal}
|
||||
downloadStatus={downloadInfo.status}
|
||||
downloadProgress={downloadInfo.progress}
|
||||
@@ -675,7 +655,6 @@
|
||||
onPlayNow: () => handleTrackClick(track),
|
||||
onPlayNext: track.isLocal ? () => handleTrackPlayNext(track) : (onTrackPlayNext ? () => onTrackPlayNext(track) : undefined),
|
||||
onPlayLater: track.isLocal ? () => handleTrackPlayLater(track) : (onTrackPlayLater ? () => onTrackPlayLater(track) : undefined),
|
||||
onAddFavorite: !track.isLocal && onTrackAddFavorite ? () => onTrackAddFavorite(track.id) : undefined,
|
||||
onAddToPlaylist: !track.isLocal && onTrackAddToPlaylist ? () => onTrackAddToPlaylist(track.id) : undefined,
|
||||
onShareQobuz: !track.isLocal && onTrackShareQobuz ? () => onTrackShareQobuz(track.id) : undefined,
|
||||
onShareSonglink: !track.isLocal && onTrackShareSonglink ? () => onTrackShareSonglink(track) : undefined,
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Favorites Store
|
||||
*
|
||||
* Centralized store for tracking favorite tracks.
|
||||
* Components can check if a track is favorite and toggle status without prop drilling.
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { logRecoEvent } from '$lib/services/recoService';
|
||||
|
||||
// State
|
||||
let favoriteTrackIds = new Set<number>();
|
||||
let isLoaded = false;
|
||||
let isLoading = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notifyListeners(): void {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Public API ============
|
||||
|
||||
/**
|
||||
* Subscribe to favorites changes
|
||||
*/
|
||||
export function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a track is in favorites
|
||||
*/
|
||||
export function isTrackFavorite(trackId: number): boolean {
|
||||
return favoriteTrackIds.has(trackId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all favorite track IDs
|
||||
*/
|
||||
export function getFavoriteTrackIds(): number[] {
|
||||
return Array.from(favoriteTrackIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if favorites have been loaded
|
||||
*/
|
||||
export function isFavoritesLoaded(): boolean {
|
||||
return isLoaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load favorites from backend (call once on app init)
|
||||
*/
|
||||
export async function loadFavorites(): Promise<void> {
|
||||
if (isLoading || isLoaded) return;
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const response = await invoke<{ tracks: { items: Array<{ id: number }> } }>('get_favorites', {
|
||||
favType: 'track',
|
||||
limit: 500
|
||||
});
|
||||
|
||||
favoriteTrackIds = new Set(response.tracks.items.map(t => t.id));
|
||||
isLoaded = true;
|
||||
notifyListeners();
|
||||
} catch (err) {
|
||||
console.error('Failed to load favorites:', err);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle favorite status for a track
|
||||
* Returns the new favorite state
|
||||
*/
|
||||
export async function toggleTrackFavorite(trackId: number): Promise<boolean> {
|
||||
const wasFavorite = favoriteTrackIds.has(trackId);
|
||||
const newState = !wasFavorite;
|
||||
|
||||
// Optimistic update
|
||||
if (newState) {
|
||||
favoriteTrackIds.add(trackId);
|
||||
} else {
|
||||
favoriteTrackIds.delete(trackId);
|
||||
}
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
if (newState) {
|
||||
await invoke('add_favorite', { favType: 'track', itemId: String(trackId) });
|
||||
void logRecoEvent({
|
||||
eventType: 'favorite',
|
||||
itemType: 'track',
|
||||
trackId
|
||||
});
|
||||
} else {
|
||||
await invoke('remove_favorite', { favType: 'track', itemId: String(trackId) });
|
||||
}
|
||||
return newState;
|
||||
} catch (err) {
|
||||
// Rollback on error
|
||||
if (newState) {
|
||||
favoriteTrackIds.delete(trackId);
|
||||
} else {
|
||||
favoriteTrackIds.add(trackId);
|
||||
}
|
||||
notifyListeners();
|
||||
console.error('Failed to toggle favorite:', err);
|
||||
return wasFavorite;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a track to favorites (used when we know it's not already favorite)
|
||||
*/
|
||||
export async function addTrackFavorite(trackId: number): Promise<boolean> {
|
||||
if (favoriteTrackIds.has(trackId)) return true;
|
||||
return toggleTrackFavorite(trackId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a track from favorites
|
||||
*/
|
||||
export async function removeTrackFavorite(trackId: number): Promise<boolean> {
|
||||
if (!favoriteTrackIds.has(trackId)) return true;
|
||||
const result = await toggleTrackFavorite(trackId);
|
||||
return !result; // Returns true if successfully removed
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually add a track ID to the local set (for when we add favorites elsewhere)
|
||||
*/
|
||||
export function markAsFavorite(trackId: number): void {
|
||||
if (!favoriteTrackIds.has(trackId)) {
|
||||
favoriteTrackIds.add(trackId);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually remove a track ID from the local set
|
||||
*/
|
||||
export function unmarkAsFavorite(trackId: number): void {
|
||||
if (favoriteTrackIds.has(trackId)) {
|
||||
favoriteTrackIds.delete(trackId);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset store (for testing or logout)
|
||||
*/
|
||||
export function reset(): void {
|
||||
favoriteTrackIds = new Set();
|
||||
isLoaded = false;
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -53,6 +53,9 @@
|
||||
type UserInfo
|
||||
} from '$lib/stores/authStore';
|
||||
|
||||
// Favorites state management
|
||||
import { loadFavorites } from '$lib/stores/favoritesStore';
|
||||
|
||||
// Navigation state management
|
||||
import {
|
||||
subscribe as subscribeNav,
|
||||
@@ -882,6 +885,9 @@
|
||||
initDownloadStates();
|
||||
startDownloadEventListeners();
|
||||
|
||||
// Load favorites for global state
|
||||
loadFavorites();
|
||||
|
||||
// Set up service toast callbacks
|
||||
setPlaybackToastCallback(showToast);
|
||||
setTrackActionsToastCallback(showToast);
|
||||
|
||||
Reference in new issue
Block a user