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:
vicrodh committed 2026-01-11 02:08:14 -06:00
1 parent 48a010c597
commit 1b90a45212
8 files changed
+229 -67

No files matched your search

+50 -19
View File
@@ -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,
+2 -3
View File
@@ -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,
+163
View File
@@ -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();
}
+6
View File
@@ -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);