Files
qbz/src/lib/components/views/FavoritesView.svelte
T
vicrodh 56c61499d9 fix: move track header outside virtualized container
- Header now outside .track-sections.virtualized (like LocalLibraryView)
- Use calc() height for virtualized section to give explicit dimensions
- Removes complex flex/absolute positioning that was breaking layout
2026-01-29 12:05:46 -06:00

2410 lines
72 KiB
Svelte

<script lang="ts">
import { invoke, convertFileSrc } from '@tauri-apps/api/core';
import { onMount, tick } from 'svelte';
import { Heart, Play, Disc3, Mic2, Music, Search, X, LayoutGrid, List, ChevronDown, ListMusic, Edit3, Star, Folder, Library, CloudDownload } from 'lucide-svelte';
import AlbumCard from '../AlbumCard.svelte';
import TrackRow from '../TrackRow.svelte';
import QualityBadge from '../QualityBadge.svelte';
import VirtualizedTrackList from '../VirtualizedTrackList.svelte';
import PlaylistCollage from '../PlaylistCollage.svelte';
import FavoritesEditModal from '../FavoritesEditModal.svelte';
import { type OfflineCacheStatus } from '$lib/stores/offlineCacheState';
import { consumeContextTrackFocus, setPlaybackContext } from '$lib/stores/playbackContextStore';
import { normalizeFavoritesTabOrder } from '$lib/utils/favorites';
import type { FavoritesPreferences } from '$lib/types';
interface FavoriteAlbum {
id: string;
title: string;
artist: { id: number; name: string };
genre?: { name: string };
image: { small?: string; thumbnail?: string; large?: string };
release_date_original?: string;
hires: boolean;
maximum_bit_depth?: number;
maximum_sampling_rate?: number;
}
interface FavoriteTrack {
id: number;
title: string;
duration: number;
track_number: number;
performer?: { id?: number; name: string };
album?: { id: string; title: string; image: { small?: string; thumbnail?: string; large?: string } };
hires: boolean;
maximum_bit_depth?: number;
maximum_sampling_rate?: number;
isrc?: string;
}
interface FavoriteArtist {
id: number;
name: string;
image?: { small?: string; thumbnail?: string; large?: string };
albums_count?: number;
}
interface FavoritePlaylist {
id: number;
name: string;
tracks_count: number;
images?: string[];
duration: number;
owner: { id: number; name: string };
}
interface Props {
onAlbumClick?: (albumId: string) => void;
onAlbumPlay?: (albumId: string) => void;
onAlbumPlayNext?: (albumId: string) => void;
onAlbumPlayLater?: (albumId: string) => void;
onAlbumShareQobuz?: (albumId: string) => void;
onAlbumShareSonglink?: (albumId: string) => void;
onAlbumDownload?: (albumId: string) => void;
onOpenAlbumFolder?: (albumId: string) => void;
onReDownloadAlbum?: (albumId: string) => void;
checkAlbumFullyDownloaded?: (albumId: string) => Promise<boolean>;
downloadStateVersion?: number;
onTrackPlay?: (track: DisplayTrack) => void;
onArtistClick?: (artistId: number) => void;
onTrackPlayNext?: (track: DisplayTrack) => void;
onTrackPlayLater?: (track: DisplayTrack) => void;
onTrackAddFavorite?: (trackId: number) => void;
onTrackAddToPlaylist?: (trackId: number) => void;
onTrackShareQobuz?: (trackId: number) => void;
onTrackShareSonglink?: (track: DisplayTrack) => void;
onTrackGoToAlbum?: (albumId: string) => void;
onTrackGoToArtist?: (artistId: number) => void;
onTrackShowInfo?: (trackId: number) => void;
onTrackDownload?: (track: DisplayTrack) => void;
onTrackRemoveDownload?: (trackId: number) => void;
onTrackReDownload?: (track: DisplayTrack) => void;
getTrackOfflineCacheStatus?: (trackId: number) => { status: OfflineCacheStatus; progress: number };
onPlaylistSelect?: (playlistId: number) => void;
selectedTab?: TabType;
onTabNavigate?: (tab: TabType) => void;
activeTrackId?: number | null;
isPlaybackActive?: boolean;
}
interface DisplayTrack {
id: number;
number: number;
title: string;
artist?: string;
album?: string;
albumArt?: string;
albumId?: string;
artistId?: number;
duration: string;
durationSeconds: number;
hires?: boolean;
bitDepth?: number;
samplingRate?: number;
isrc?: string;
}
let {
onAlbumClick,
onAlbumPlay,
onAlbumPlayNext,
onAlbumPlayLater,
onAlbumShareQobuz,
onAlbumShareSonglink,
onAlbumDownload,
onOpenAlbumFolder,
onReDownloadAlbum,
checkAlbumFullyDownloaded,
downloadStateVersion,
onTrackPlay,
onArtistClick,
onTrackPlayNext,
onTrackPlayLater,
onTrackAddFavorite,
onTrackAddToPlaylist,
onTrackShareQobuz,
onTrackShareSonglink,
onTrackGoToAlbum,
onTrackGoToArtist,
onTrackShowInfo,
onTrackDownload,
onTrackRemoveDownload,
onTrackReDownload,
getTrackOfflineCacheStatus,
onPlaylistSelect,
selectedTab,
onTabNavigate,
activeTrackId = null,
isPlaybackActive = false
}: Props = $props();
type TabType = 'tracks' | 'albums' | 'artists' | 'playlists';
let activeTab = $state<TabType>('tracks');
let preferencesLoaded = $state(false);
const tabLabels: Record<TabType, string> = {
tracks: 'Tracks',
albums: 'Albums',
artists: 'Artists',
playlists: 'Playlists',
};
let favoriteAlbums = $state<FavoriteAlbum[]>([]);
let favoriteTracks = $state<FavoriteTrack[]>([]);
let favoriteArtists = $state<FavoriteArtist[]>([]);
let favoritePlaylists = $state<FavoritePlaylist[]>([]);
let loading = $state(false);
let loadingPlaylists = $state(false);
let editModalOpen = $state(false);
let scrollContainer: HTMLDivElement | null = $state(null);
let favoritesPreferences = $state<FavoritesPreferences>({
custom_icon_path: null,
custom_icon_preset: 'heart',
icon_background: null,
tab_order: ['tracks', 'albums', 'artists', 'playlists'],
});
// Download status tracking
let albumOfflineCacheStatuses = $state<Map<string, boolean>>(new Map());
async function loadAlbumOfflineCacheStatus(albumId: string) {
if (!checkAlbumFullyDownloaded) return false;
try {
const isDownloaded = await checkAlbumFullyDownloaded(albumId);
albumOfflineCacheStatuses.set(albumId, isDownloaded);
return isDownloaded;
} catch {
return false;
}
}
async function loadAllAlbumOfflineCacheStatuses(albums: { id: string }[]) {
if (!checkAlbumFullyDownloaded || albums.length === 0) return;
await Promise.all(albums.map(album => loadAlbumOfflineCacheStatus(album.id)));
}
function isAlbumDownloaded(albumId: string): boolean {
void downloadStateVersion;
return albumOfflineCacheStatuses.get(albumId) || false;
}
let error = $state<string | null>(null);
// Search state for each tab
let trackSearch = $state('');
let albumSearch = $state('');
let artistSearch = $state('');
let playlistSearch = $state('');
let searchExpanded = $state(false);
let albumViewMode = $state<'grid' | 'list'>('grid');
type AlbumGroupMode = 'alpha' | 'artist';
let albumGroupMode = $state<AlbumGroupMode>('alpha');
let showAlbumGroupMenu = $state(false);
let albumGroupingEnabled = $state(false);
// Album sorting
type AlbumSortMode = 'default' | 'newest' | 'oldest' | 'title-asc' | 'title-desc' | 'artist';
let albumSortMode = $state<AlbumSortMode>('default');
let showAlbumSortMenu = $state(false);
type TrackGroupMode = 'album' | 'artist' | 'name';
let trackGroupMode = $state<TrackGroupMode>('album');
let showTrackGroupMenu = $state(false);
let trackGroupingEnabled = $state(false);
let showArtistGroupMenu = $state(false);
let artistGroupingEnabled = $state(false);
function resolveCustomIconSrc(path: string | null): string | null {
if (!path) return null;
if (path.startsWith('asset://') || path.startsWith('http://asset.localhost') || path.startsWith('https://asset.localhost')) {
return path;
}
if (path.startsWith('file://')) {
return path;
}
return convertFileSrc(path);
}
let customIconSrc = $derived.by(() => resolveCustomIconSrc(favoritesPreferences.custom_icon_path));
async function scrollToTrack(trackId: number) {
await tick();
const target = scrollContainer?.querySelector<HTMLElement>(`[data-track-id="${trackId}"]`);
target?.scrollIntoView({ block: 'center' });
}
// Filtered lists based on search
let filteredTracks = $derived.by(() => {
if (!trackSearch.trim()) return favoriteTracks;
const query = trackSearch.toLowerCase();
return favoriteTracks.filter(t =>
t.title.toLowerCase().includes(query) ||
t.performer?.name?.toLowerCase().includes(query) ||
t.album?.title?.toLowerCase().includes(query)
);
});
let trackIndexMap = $derived.by(() => {
return new Map(filteredTracks.map((track, index) => [track.id, index]));
});
let filteredAlbums = $derived.by(() => {
let albums = favoriteAlbums;
// Filter by search
if (albumSearch.trim()) {
const query = albumSearch.toLowerCase();
albums = albums.filter(a =>
a.title.toLowerCase().includes(query) ||
a.artist.name.toLowerCase().includes(query)
);
}
// Sort albums
if (albumSortMode !== 'default') {
albums = [...albums].sort((a, b) => {
switch (albumSortMode) {
case 'newest': {
const dateA = a.release_date_original || '0000';
const dateB = b.release_date_original || '0000';
return dateB.localeCompare(dateA);
}
case 'oldest': {
const dateA = a.release_date_original || '9999';
const dateB = b.release_date_original || '9999';
return dateA.localeCompare(dateB);
}
case 'title-asc':
return a.title.localeCompare(b.title);
case 'title-desc':
return b.title.localeCompare(a.title);
case 'artist':
return a.artist.name.localeCompare(b.artist.name);
default:
return 0;
}
});
}
return albums;
});
let filteredArtists = $derived.by(() => {
if (!artistSearch.trim()) return favoriteArtists;
const query = artistSearch.toLowerCase();
return favoriteArtists.filter(a =>
a.name.toLowerCase().includes(query)
);
});
let filteredPlaylists = $derived.by(() => {
if (!playlistSearch.trim()) return favoritePlaylists;
const query = playlistSearch.toLowerCase();
return favoritePlaylists.filter(p =>
p.name.toLowerCase().includes(query) ||
p.owner.name.toLowerCase().includes(query)
);
});
function loadStoredBool(key: string, fallback = false): boolean {
try {
const value = localStorage.getItem(key);
if (value === null) return fallback;
return value === 'true';
} catch {
return fallback;
}
}
function loadStoredString<T extends string>(key: string, fallback: T, options: T[]): T {
try {
const value = localStorage.getItem(key);
if (value && (options as string[]).includes(value)) {
return value as T;
}
} catch {
return fallback;
}
return fallback;
}
function getCurrentSearchValue(): string {
switch (activeTab) {
case 'tracks': return trackSearch;
case 'albums': return albumSearch;
case 'artists': return artistSearch;
case 'playlists': return playlistSearch;
default: return '';
}
}
function setCurrentSearchValue(value: string) {
switch (activeTab) {
case 'tracks': trackSearch = value; break;
case 'albums': albumSearch = value; break;
case 'artists': artistSearch = value; break;
case 'playlists': playlistSearch = value; break;
}
}
function clearCurrentSearch() {
setCurrentSearchValue('');
searchExpanded = false;
}
function getTabIcon(tab: TabType) {
switch (tab) {
case 'tracks': return Music;
case 'albums': return Disc3;
case 'artists': return Mic2;
case 'playlists': return ListMusic;
}
}
function getTabLabel(tab: TabType): string {
return tabLabels[tab] || tab.charAt(0).toUpperCase() + tab.slice(1);
}
onMount(() => {
albumViewMode = loadStoredString('qbz-favorites-album-view', 'grid', ['grid', 'list']);
albumGroupMode = loadStoredString('qbz-favorites-album-group', 'alpha', ['alpha', 'artist']);
albumSortMode = loadStoredString('qbz-favorites-album-sort', 'default', ['default', 'newest', 'oldest', 'title-asc', 'title-desc', 'artist']);
trackGroupMode = loadStoredString('qbz-favorites-track-group', 'album', ['album', 'artist', 'name']);
albumGroupingEnabled = loadStoredBool('qbz-favorites-album-group-enabled', false);
trackGroupingEnabled = loadStoredBool('qbz-favorites-track-group-enabled', false);
artistGroupingEnabled = loadStoredBool('qbz-favorites-artist-group-enabled', false);
loadFavoritesPreferences().then(() => {
preferencesLoaded = true;
if (selectedTab) {
activeTab = selectedTab;
} else {
activeTab = favoritesPreferences.tab_order[0] as TabType;
}
loadTabIfNeeded(activeTab);
});
});
async function loadFavoritesPreferences() {
try {
const prefs = await invoke<FavoritesPreferences>('get_favorites_preferences');
favoritesPreferences = {
...prefs,
tab_order: normalizeFavoritesTabOrder(prefs.tab_order)
};
} catch (err) {
console.error('Failed to load favorites preferences:', err);
favoritesPreferences = {
...favoritesPreferences,
tab_order: normalizeFavoritesTabOrder(favoritesPreferences.tab_order)
};
}
}
function handlePreferencesSaved(prefs: FavoritesPreferences) {
favoritesPreferences = {
...prefs,
tab_order: normalizeFavoritesTabOrder(prefs.tab_order)
};
}
$effect(() => {
try {
localStorage.setItem('qbz-favorites-album-view', albumViewMode);
localStorage.setItem('qbz-favorites-album-group', albumGroupMode);
localStorage.setItem('qbz-favorites-album-sort', albumSortMode);
localStorage.setItem('qbz-favorites-track-group', trackGroupMode);
localStorage.setItem('qbz-favorites-album-group-enabled', String(albumGroupingEnabled));
localStorage.setItem('qbz-favorites-track-group-enabled', String(trackGroupingEnabled));
localStorage.setItem('qbz-favorites-artist-group-enabled', String(artistGroupingEnabled));
} catch {
// localStorage not available
}
});
$effect(() => {
if (preferencesLoaded && selectedTab && selectedTab !== activeTab) {
activeTab = selectedTab;
loadTabIfNeeded(activeTab);
}
});
$effect(() => {
if (!preferencesLoaded || activeTab !== 'tracks' || favoriteTracks.length === 0) return;
const targetId = consumeContextTrackFocus('favorites', 'favorites');
if (targetId !== null) {
void scrollToTrack(targetId);
}
});
const FAVORITES_PAGE_SIZE = 200;
const FAVORITES_MAX_PAGES = 50;
function extractFavoritesPayload(result: any, type: TabType) {
const payload = result?.[type];
const items = Array.isArray(payload?.items) ? payload.items : [];
const total = typeof payload?.total === 'number'
? payload.total
: typeof payload?.count === 'number'
? payload.count
: null;
return { items, total };
}
async function fetchAllFavorites(type: TabType) {
let offset = 0;
let page = 0;
let total: number | null = null;
const collected: any[] = [];
while (page < FAVORITES_MAX_PAGES) {
const result = await invoke<any>('get_favorites', {
favType: type,
limit: FAVORITES_PAGE_SIZE,
offset
});
const { items, total: batchTotal } = extractFavoritesPayload(result, type);
if (!items.length) break;
collected.push(...items);
offset += items.length;
total = total ?? batchTotal;
if (total !== null && offset >= total) break;
if (items.length < FAVORITES_PAGE_SIZE) break;
page += 1;
}
return collected;
}
async function loadFavorites(type: TabType) {
loading = true;
error = null;
try {
const items = await fetchAllFavorites(type);
if (type === 'tracks') {
favoriteTracks = items as FavoriteTrack[];
} else if (type === 'albums') {
favoriteAlbums = items as FavoriteAlbum[];
await loadAllAlbumOfflineCacheStatuses(favoriteAlbums);
} else if (type === 'artists') {
favoriteArtists = items as FavoriteArtist[];
}
} catch (err) {
console.error(`Failed to load ${type} favorites:`, err);
error = String(err);
} finally {
loading = false;
}
}
async function loadFavoritePlaylists() {
loadingPlaylists = true;
error = null;
try {
// Get IDs of favorited playlists from local DB
const favoriteIds = await invoke<number[]>('playlist_get_favorites');
if (favoriteIds.length === 0) {
favoritePlaylists = [];
return;
}
// Fetch full playlist data for each favorited playlist
const playlists: FavoritePlaylist[] = [];
for (const id of favoriteIds) {
try {
const playlist = await invoke<FavoritePlaylist>('get_playlist', { playlistId: id });
playlists.push(playlist);
} catch (err) {
console.warn(`Failed to load playlist ${id}:`, err);
}
}
favoritePlaylists = playlists;
} catch (err) {
console.error('Failed to load favorite playlists:', err);
error = String(err);
} finally {
loadingPlaylists = false;
}
}
function loadTabIfNeeded(tab: TabType) {
if (tab === 'tracks' && favoriteTracks.length === 0) {
loadFavorites(tab);
} else if (tab === 'albums' && favoriteAlbums.length === 0) {
loadFavorites(tab);
} else if (tab === 'artists' && favoriteArtists.length === 0) {
loadFavorites(tab);
} else if (tab === 'playlists' && favoritePlaylists.length === 0) {
loadFavoritePlaylists();
}
}
function handleTabChange(tab: TabType) {
activeTab = tab;
showAlbumGroupMenu = false;
showTrackGroupMenu = false;
showArtistGroupMenu = false;
onTabNavigate?.(tab);
loadTabIfNeeded(tab);
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function getGenreLabel(album: FavoriteAlbum): string {
return album.genre?.name || 'Unknown genre'
}
function getQualityLabel(item: { hires?: boolean; maximum_bit_depth?: number; maximum_sampling_rate?: number }): string {
if (item.hires && item.maximum_bit_depth && item.maximum_sampling_rate) {
return `${item.maximum_bit_depth}bit/${item.maximum_sampling_rate}kHz`;
}
return item.hires ? 'Hi-Res' : 'CD Quality';
}
const alphaIndexLetters = ['#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'];
function alphaGroupKey(value: string): string {
const trimmed = value.trim();
if (!trimmed) return '#';
const first = trimmed[0].toUpperCase();
return first >= 'A' && first <= 'Z' ? first : '#';
}
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'group';
}
function groupIdForKey(prefix: string, key: string): string {
if (key === '#') {
return `${prefix}-num`;
}
// Use encodeURIComponent instead of slugify to preserve case sensitivity
// This prevents collisions like "Aki" and "AKI" both becoming "aki"
return `${prefix}-${encodeURIComponent(key)}`;
}
function scrollToGroup(prefix: string, letter: string, available: Set<string>) {
if (!available.has(letter)) return;
const id = groupIdForKey(prefix, letter);
const target = document.getElementById(id);
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function getAlbumYear(album: FavoriteAlbum): string | null {
if (!album.release_date_original) return null;
return album.release_date_original.slice(0, 4);
}
function groupAlbums(items: FavoriteAlbum[], mode: AlbumGroupMode) {
const prefix = `album-${mode}`;
const sorted = [...items].sort((a, b) => {
if (mode === 'artist') {
const artistCmp = a.artist.name.localeCompare(b.artist.name);
if (artistCmp !== 0) return artistCmp;
}
return a.title.localeCompare(b.title);
});
const groups = new Map<string, FavoriteAlbum[]>();
for (const album of sorted) {
const key = mode === 'artist' ? album.artist.name : alphaGroupKey(album.title);
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)?.push(album);
}
const keys = [...groups.keys()].sort((a, b) => {
if (mode === 'alpha') {
if (a === '#') return -1;
if (b === '#') return 1;
}
return a.localeCompare(b);
});
return keys.map(key => ({
key,
id: groupIdForKey(prefix, key),
albums: groups.get(key) ?? []
}));
}
function groupArtists(items: FavoriteArtist[]) {
const prefix = 'artist-alpha';
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
const groups = new Map<string, FavoriteArtist[]>();
for (const artist of sorted) {
const key = alphaGroupKey(artist.name);
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)?.push(artist);
}
const keys = [...groups.keys()].sort((a, b) => {
if (a === '#') return -1;
if (b === '#') return 1;
return a.localeCompare(b);
});
return keys.map(key => ({
key,
id: groupIdForKey(prefix, key),
artists: groups.get(key) ?? []
}));
}
function groupTracks(items: FavoriteTrack[], mode: TrackGroupMode) {
const prefix = `track-${mode}`;
const sorted = [...items].sort((a, b) => {
if (mode === 'album') {
const albumCmp = (a.album?.title || '').localeCompare(b.album?.title || '');
if (albumCmp !== 0) return albumCmp;
const trackCmp = (a.track_number || 0) - (b.track_number || 0);
if (trackCmp !== 0) return trackCmp;
return a.title.localeCompare(b.title);
}
if (mode === 'artist') {
const artistCmp = (a.performer?.name || '').localeCompare(b.performer?.name || '');
if (artistCmp !== 0) return artistCmp;
const albumCmp = (a.album?.title || '').localeCompare(b.album?.title || '');
if (albumCmp !== 0) return albumCmp;
const trackCmp = (a.track_number || 0) - (b.track_number || 0);
if (trackCmp !== 0) return trackCmp;
return a.title.localeCompare(b.title);
}
const titleCmp = a.title.localeCompare(b.title);
if (titleCmp !== 0) return titleCmp;
return (a.performer?.name || '').localeCompare(b.performer?.name || '');
});
const groups = new Map<string, { title: string; subtitle?: string; tracks: FavoriteTrack[]; artists: Set<string> }>();
for (const track of sorted) {
if (mode === 'album') {
const title = track.album?.title || 'Unknown Album';
const groupKey = track.album?.id || title;
const artistName = track.performer?.name || 'Unknown Artist';
const entry = groups.get(groupKey);
if (!entry) {
groups.set(groupKey, {
title,
subtitle: artistName,
tracks: [track],
artists: new Set([artistName])
});
} else {
entry.tracks.push(track);
entry.artists.add(artistName);
}
} else if (mode === 'artist') {
const key = track.performer?.name || 'Unknown Artist';
if (!groups.has(key)) {
groups.set(key, { title: key, tracks: [], artists: new Set([key]) });
}
groups.get(key)?.tracks.push(track);
} else {
const key = alphaGroupKey(track.title);
if (!groups.has(key)) {
groups.set(key, { title: key, tracks: [], artists: new Set() });
}
groups.get(key)?.tracks.push(track);
}
}
const keys = [...groups.keys()].sort((a, b) => {
if (mode === 'name') {
if (a === '#') return -1;
if (b === '#') return 1;
}
if (mode === 'album') {
const titleCmp = (groups.get(a)?.title ?? a).localeCompare(groups.get(b)?.title ?? b);
if (titleCmp !== 0) return titleCmp;
}
return a.localeCompare(b);
});
return keys.map(key => {
const entry = groups.get(key);
if (!entry) {
return { key, id: groupIdForKey(prefix, key), title: key, tracks: [] as FavoriteTrack[] };
}
let subtitle = entry.subtitle;
if (mode === 'album') {
const artists = [...entry.artists];
subtitle = artists.length > 1 ? 'Various Artists' : artists[0];
}
return {
key,
id: groupIdForKey(prefix, key),
title: entry.title,
subtitle,
tracks: entry.tracks
};
});
}
function buildDisplayTrack(track: FavoriteTrack, index: number): DisplayTrack {
return {
id: track.id,
number: index + 1,
title: track.title,
artist: track.performer?.name,
album: track.album?.title,
albumArt: track.album?.image?.large || track.album?.image?.thumbnail || track.album?.image?.small,
albumId: track.album?.id,
artistId: track.performer?.id,
duration: formatDuration(track.duration),
durationSeconds: track.duration,
hires: track.hires,
bitDepth: track.maximum_bit_depth,
samplingRate: track.maximum_sampling_rate,
isrc: track.isrc,
};
}
function buildFavoritesQueueTracks(tracks: FavoriteTrack[]) {
return tracks.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?.large || t.album?.image?.thumbnail || t.album?.image?.small || '',
hires: t.hires ?? false,
bit_depth: t.maximum_bit_depth ?? null,
sample_rate: t.maximum_sampling_rate ?? null,
is_local: false,
album_id: t.album?.id || null,
artist_id: t.performer?.id ?? null,
}));
}
// Accessor functions for VirtualizedTrackList (adapts FavoriteTrack to expected interface)
const getFavoriteTrackId = (t: FavoriteTrack) => t.id;
const getFavoriteTrackNumber = (t: FavoriteTrack, idx: number) => t.track_number || idx + 1;
const getFavoriteTrackTitle = (t: FavoriteTrack) => t.title;
const getFavoriteTrackArtist = (t: FavoriteTrack) => t.performer?.name;
const getFavoriteTrackDuration = (t: FavoriteTrack) => t.duration;
const getFavoriteTrackAlbumKey = (t: FavoriteTrack) => t.album?.id;
const getFavoriteTrackAlbum = (t: FavoriteTrack) => t.album?.title;
const getFavoriteArtistId = (t: FavoriteTrack) => t.performer?.id;
const getFavoriteAlbumId = (t: FavoriteTrack) => t.album?.id;
// VirtualizedTrackList requires this but we don't have disc info in favorites
function buildFavoritesAlbumSections(tracks: FavoriteTrack[]) {
return [{ disc: 1, label: 'Disc 1', tracks }];
}
// Handler for virtualized track play (needs index lookup)
function handleVirtualizedTrackPlay(track: FavoriteTrack) {
const index = trackIndexMap.get(track.id) ?? 0;
handleTrackClick(track, index);
}
// Build display track for virtualized list handlers
function buildDisplayTrackFromFavorite(track: FavoriteTrack): DisplayTrack {
const index = trackIndexMap.get(track.id) ?? 0;
return buildDisplayTrack(track, index);
}
async function setFavoritesContext(trackIds: number[], index: number) {
if (trackIds.length === 0) return;
await setPlaybackContext(
'favorites',
'favorites',
'Favorites',
'qobuz',
trackIds,
index
);
}
async function setFavoritesQueue(startIndex: number) {
if (filteredTracks.length === 0) return;
const queueTracks = buildFavoritesQueueTracks(filteredTracks);
await invoke('set_queue', { tracks: queueTracks, startIndex });
}
async function handleTrackClick(track: FavoriteTrack, index: number) {
const trackIds = filteredTracks.map(t => t.id);
await setFavoritesContext(trackIds, index);
try {
await setFavoritesQueue(index);
} catch (err) {
console.error('Failed to set queue:', err);
}
if (onTrackPlay) {
onTrackPlay(buildDisplayTrack(track, index));
}
}
async function handlePlayAllTracks() {
if (filteredTracks.length === 0 || !onTrackPlay) return;
try {
await setFavoritesQueue(0);
await setFavoritesContext(filteredTracks.map(t => t.id), 0);
onTrackPlay(buildDisplayTrack(filteredTracks[0], 0));
} catch (err) {
console.error('Failed to set queue:', err);
}
}
</script>
<div class="favorites-view" bind:this={scrollContainer}>
<!-- Header -->
<div class="header">
<div
class="header-icon"
style={favoritesPreferences.icon_background ? `background: ${favoritesPreferences.icon_background};` : ''}
>
{#if customIconSrc}
<img
src={customIconSrc}
alt="Custom Icon"
class="custom-icon-img"
/>
{:else if favoritesPreferences.custom_icon_preset}
{#if favoritesPreferences.custom_icon_preset === 'heart'}
<Heart size={32} fill="var(--accent-primary)" color="var(--accent-primary)" />
{:else if favoritesPreferences.custom_icon_preset === 'star'}
<svelte:component this={Star} size={32} fill="var(--accent-primary)" color="var(--accent-primary)" />
{:else if favoritesPreferences.custom_icon_preset === 'music'}
<Music size={32} color="var(--accent-primary)" />
{:else if favoritesPreferences.custom_icon_preset === 'folder'}
<svelte:component this={Folder} size={32} color="var(--accent-primary)" />
{:else if favoritesPreferences.custom_icon_preset === 'disc'}
<Disc3 size={32} color="var(--accent-primary)" />
{:else if favoritesPreferences.custom_icon_preset === 'library'}
<svelte:component this={Library} size={32} color="var(--accent-primary)" />
{/if}
{:else}
<Heart size={32} fill="var(--accent-primary)" color="var(--accent-primary)" />
{/if}
</div>
<div class="header-content">
<h1>Favorites</h1>
<p class="subtitle">Your liked tracks, albums, and artists</p>
</div>
<button class="edit-btn" onclick={() => editModalOpen = true} title="Edit Favorites settings">
<Edit3 size={16} />
</button>
</div>
<!-- Navigation Bar (Artist-style) -->
<div class="favorites-nav">
<div class="nav-left">
{#each favoritesPreferences.tab_order as tab}
<button
class="nav-link"
class:active={activeTab === tab}
onclick={() => handleTabChange(tab as TabType)}
>
<svelte:component this={getTabIcon(tab as TabType)} size={16} />
<span>{getTabLabel(tab as TabType)}</span>
</button>
{/each}
</div>
<div class="nav-right">
{#if !searchExpanded}
<button class="search-icon-btn" onclick={() => searchExpanded = true} title="Search">
<Search size={16} />
</button>
{:else}
<div class="search-expanded">
<Search size={16} class="search-icon-inline" />
<input
type="text"
placeholder={`Search ${getTabLabel(activeTab).toLowerCase()}...`}
value={getCurrentSearchValue()}
oninput={(e) => setCurrentSearchValue(e.currentTarget.value)}
class="search-input-inline"
autofocus
/>
{#if getCurrentSearchValue()}
<button class="search-clear-btn" onclick={clearCurrentSearch} title="Clear">
<X size={14} />
</button>
{/if}
</div>
{/if}
</div>
</div>
<!-- Toolbar with actions -->
<div class="toolbar">
{#if activeTab === 'albums'}
<div class="toolbar-controls">
<div class="dropdown-container">
<button class="control-btn" onclick={() => (showAlbumGroupMenu = !showAlbumGroupMenu)}>
<span>
{albumGroupingEnabled
? albumGroupMode === 'alpha'
? 'Group: A-Z'
: 'Group: Artist'
: 'Group: Off'}
</span>
<ChevronDown size={14} />
</button>
{#if showAlbumGroupMenu}
<div class="dropdown-menu">
<button
class="dropdown-item"
class:selected={!albumGroupingEnabled}
onclick={() => { albumGroupingEnabled = false; showAlbumGroupMenu = false; }}
>
Off
</button>
<button
class="dropdown-item"
class:selected={albumGroupingEnabled && albumGroupMode === 'alpha'}
onclick={() => { albumGroupMode = 'alpha'; albumGroupingEnabled = true; showAlbumGroupMenu = false; }}
>
Alphabetical (A-Z)
</button>
<button
class="dropdown-item"
class:selected={albumGroupingEnabled && albumGroupMode === 'artist'}
onclick={() => { albumGroupMode = 'artist'; albumGroupingEnabled = true; showAlbumGroupMenu = false; }}
>
Artist
</button>
</div>
{/if}
</div>
<div class="dropdown-container">
<button class="control-btn" onclick={() => (showAlbumSortMenu = !showAlbumSortMenu)}>
<span>
{#if albumSortMode === 'default'}Sort: Default
{:else if albumSortMode === 'newest'}Sort: Newest
{:else if albumSortMode === 'oldest'}Sort: Oldest
{:else if albumSortMode === 'title-asc'}Sort: A-Z
{:else if albumSortMode === 'title-desc'}Sort: Z-A
{:else if albumSortMode === 'artist'}Sort: Artist
{/if}
</span>
<ChevronDown size={14} />
</button>
{#if showAlbumSortMenu}
<div class="dropdown-menu">
<button
class="dropdown-item"
class:selected={albumSortMode === 'default'}
onclick={() => { albumSortMode = 'default'; showAlbumSortMenu = false; }}
>
Default
</button>
<button
class="dropdown-item"
class:selected={albumSortMode === 'newest'}
onclick={() => { albumSortMode = 'newest'; showAlbumSortMenu = false; }}
>
Newest First
</button>
<button
class="dropdown-item"
class:selected={albumSortMode === 'oldest'}
onclick={() => { albumSortMode = 'oldest'; showAlbumSortMenu = false; }}
>
Oldest First
</button>
<button
class="dropdown-item"
class:selected={albumSortMode === 'title-asc'}
onclick={() => { albumSortMode = 'title-asc'; showAlbumSortMenu = false; }}
>
Title (A-Z)
</button>
<button
class="dropdown-item"
class:selected={albumSortMode === 'title-desc'}
onclick={() => { albumSortMode = 'title-desc'; showAlbumSortMenu = false; }}
>
Title (Z-A)
</button>
<button
class="dropdown-item"
class:selected={albumSortMode === 'artist'}
onclick={() => { albumSortMode = 'artist'; showAlbumSortMenu = false; }}
>
Artist
</button>
</div>
{/if}
</div>
<button
class="icon-btn"
onclick={() => (albumViewMode = albumViewMode === 'grid' ? 'list' : 'grid')}
title={albumViewMode === 'grid' ? 'List view' : 'Grid view'}
>
{#if albumViewMode === 'grid'}
<List size={16} />
{:else}
<LayoutGrid size={16} />
{/if}
</button>
</div>
{:else if activeTab === 'tracks'}
<div class="toolbar-controls">
<div class="dropdown-container">
<button class="control-btn" onclick={() => (showTrackGroupMenu = !showTrackGroupMenu)}>
<span>
{trackGroupingEnabled
? trackGroupMode === 'album'
? 'Group: Album'
: trackGroupMode === 'artist'
? 'Group: Artist'
: 'Group: Name'
: 'Group: Off'}
</span>
<ChevronDown size={14} />
</button>
{#if showTrackGroupMenu}
<div class="dropdown-menu">
<button
class="dropdown-item"
class:selected={!trackGroupingEnabled}
onclick={() => { trackGroupingEnabled = false; showTrackGroupMenu = false; }}
>
Off
</button>
<button
class="dropdown-item"
class:selected={trackGroupingEnabled && trackGroupMode === 'album'}
onclick={() => { trackGroupMode = 'album'; trackGroupingEnabled = true; showTrackGroupMenu = false; }}
>
Album
</button>
<button
class="dropdown-item"
class:selected={trackGroupingEnabled && trackGroupMode === 'artist'}
onclick={() => { trackGroupMode = 'artist'; trackGroupingEnabled = true; showTrackGroupMenu = false; }}
>
Artist
</button>
<button
class="dropdown-item"
class:selected={trackGroupingEnabled && trackGroupMode === 'name'}
onclick={() => { trackGroupMode = 'name'; trackGroupingEnabled = true; showTrackGroupMenu = false; }}
>
Name (A-Z)
</button>
</div>
{/if}
</div>
</div>
{:else if activeTab === 'artists'}
<div class="toolbar-controls">
<div class="dropdown-container">
<button class="control-btn" onclick={() => (showArtistGroupMenu = !showArtistGroupMenu)}>
<span>{artistGroupingEnabled ? 'Group: A-Z' : 'Group: Off'}</span>
<ChevronDown size={14} />
</button>
{#if showArtistGroupMenu}
<div class="dropdown-menu">
<button
class="dropdown-item"
class:selected={!artistGroupingEnabled}
onclick={() => { artistGroupingEnabled = false; showArtistGroupMenu = false; }}
>
Off
</button>
<button
class="dropdown-item"
class:selected={artistGroupingEnabled}
onclick={() => { artistGroupingEnabled = true; showArtistGroupMenu = false; }}
>
Alphabetical (A-Z)
</button>
</div>
{/if}
</div>
</div>
{/if}
<!-- Actions (for tracks tab) -->
{#if activeTab === 'tracks' && filteredTracks.length > 0}
<div class="actions">
<button class="play-btn" onclick={handlePlayAllTracks}>
<Play size={16} fill="white" />
<span>Play All</span>
</button>
</div>
{/if}
<!-- Results count -->
<span class="results-count">
{#if activeTab === 'tracks'}
{filteredTracks.length}{trackSearch ? ` / ${favoriteTracks.length}` : ''} tracks
{:else if activeTab === 'albums'}
{filteredAlbums.length}{albumSearch ? ` / ${favoriteAlbums.length}` : ''} albums
{:else if activeTab === 'artists'}
{filteredArtists.length}{artistSearch ? ` / ${favoriteArtists.length}` : ''} artists
{:else}
{filteredPlaylists.length}{playlistSearch ? ` / ${favoritePlaylists.length}` : ''} playlists
{/if}
</span>
</div>
<!-- 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={() => loadTabIfNeeded(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 if filteredTracks.length === 0}
<div class="empty">
<Search size={48} />
<p>No tracks match "{trackSearch}"</p>
</div>
{:else}
{@const groupedTracks = trackGroupingEnabled
? groupTracks(filteredTracks, trackGroupMode)
: [{ key: '', id: 'ungrouped', title: '', tracks: filteredTracks }]}
{@const trackIndexTargets = trackGroupMode === 'artist' && trackGroupingEnabled
? (() => {
const map = new Map<string, string>();
for (const group of groupedTracks) {
const letter = alphaGroupKey(group.title);
if (!map.has(letter)) {
map.set(letter, group.id);
}
}
return map;
})()
: new Map<string, string>()}
{@const trackAlphaGroups = trackGroupingEnabled && trackGroupMode === 'name'
? new Set(groupedTracks.map(group => group.key))
: trackGroupingEnabled && trackGroupMode === 'artist'
? new Set(trackIndexTargets.keys())
: new Set<string>()}
<!-- Track list header - outside virtualized container -->
<div class="track-list-header">
<div class="col-number">#</div>
<div class="col-title">Title</div>
{#if !trackGroupingEnabled || trackGroupMode !== 'album'}
<div class="col-album">Album</div>
{/if}
<div class="col-duration">Duration</div>
<div class="col-quality">Quality</div>
<div class="col-icon"><Heart size={14} /></div>
<div class="col-icon"><CloudDownload size={14} /></div>
<div class="col-spacer"></div>
</div>
<!-- Virtualized track list -->
<div class="track-sections virtualized">
<div class="virtualized-container">
<VirtualizedTrackList
groups={groupedTracks}
groupingEnabled={trackGroupingEnabled}
groupMode={trackGroupMode}
{activeTrackId}
{isPlaybackActive}
{formatDuration}
getQualityBadge={getQualityLabel}
buildAlbumSections={buildFavoritesAlbumSections}
onTrackPlay={handleVirtualizedTrackPlay}
onTrackPlayNext={onTrackPlayNext ? (t) => onTrackPlayNext(buildDisplayTrackFromFavorite(t)) : undefined}
onTrackPlayLater={onTrackPlayLater ? (t) => onTrackPlayLater(buildDisplayTrackFromFavorite(t)) : undefined}
onTrackAddToPlaylist={onTrackAddToPlaylist}
getTrackId={getFavoriteTrackId}
getTrackNumber={getFavoriteTrackNumber}
getTrackTitle={getFavoriteTrackTitle}
getTrackArtist={getFavoriteTrackArtist}
getTrackDuration={getFavoriteTrackDuration}
getTrackAlbumKey={getFavoriteTrackAlbumKey}
getTrackAlbum={getFavoriteTrackAlbum}
showAlbum={!trackGroupingEnabled || trackGroupMode !== 'album'}
getArtistId={getFavoriteArtistId}
getAlbumId={getFavoriteAlbumId}
isLocal={false}
hideDownload={false}
hideFavorite={false}
isFavoriteOverride={true}
getOfflineCacheStatus={getTrackOfflineCacheStatus}
onDownload={onTrackDownload ? (t) => onTrackDownload(buildDisplayTrackFromFavorite(t)) : undefined}
onRemoveDownload={onTrackRemoveDownload}
onShareQobuz={onTrackShareQobuz}
onShareSonglink={onTrackShareSonglink ? (t) => onTrackShareSonglink(buildDisplayTrackFromFavorite(t)) : undefined}
onGoToAlbum={onTrackGoToAlbum}
onGoToArtist={onTrackGoToArtist}
onShowInfo={onTrackShowInfo}
onReDownload={onTrackReDownload ? (t) => onTrackReDownload(buildDisplayTrackFromFavorite(t)) : undefined}
/>
</div>
{#if trackGroupingEnabled && (trackGroupMode === 'name' || trackGroupMode === 'artist')}
<div class="alpha-index">
{#each alphaIndexLetters as letter}
<button
class="alpha-letter"
class:disabled={!trackAlphaGroups.has(letter)}
onclick={() => scrollToGroup(trackGroupMode === 'name' ? 'track-name' : 'track-artist', letter, trackAlphaGroups)}
>
{letter}
</button>
{/each}
</div>
{/if}
</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 if filteredAlbums.length === 0}
<div class="empty">
<Search size={48} />
<p>No albums match "{albumSearch}"</p>
</div>
{:else if albumGroupingEnabled}
{@const groupedAlbums = groupAlbums(filteredAlbums, albumGroupMode)}
{@const alphaGroups = albumGroupMode === 'alpha'
? new Set(groupedAlbums.map(group => group.key))
: new Set<string>()}
<div class="album-sections">
<div class="album-group-list">
{#each groupedAlbums as group (group.id)}
<div class="album-group" id={group.id}>
<div class="album-group-header">
<span class="album-group-title">{group.key}</span>
<span class="album-group-count">{group.albums.length}</span>
</div>
{#if albumViewMode === 'grid'}
<div class="album-grid">
{#each group.albums as album (album.id)}
<AlbumCard
albumId={album.id}
artwork={album.image?.large || album.image?.thumbnail || ''}
title={album.title}
artist={album.artist.name}
genre={getGenreLabel(album)}
releaseDate={album.release_date_original}
size="large"
quality={getQualityLabel(album)}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
isAlbumFullyDownloaded={isAlbumDownloaded(album.id)}
onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined}
onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined}
{downloadStateVersion}
onclick={() => { onAlbumClick?.(album.id); loadAlbumOfflineCacheStatus(album.id); }}
/>
{/each}
</div>
{:else}
<div class="album-list">
{#each group.albums as album (album.id)}
<div class="album-row" role="button" tabindex="0" onclick={() => onAlbumClick?.(album.id)}>
<div class="album-row-art">
{#if album.image?.thumbnail || album.image?.small || album.image?.large}
<img src={album.image?.thumbnail || album.image?.small || album.image?.large} alt={album.title} loading="lazy" decoding="async" />
{:else}
<div class="artwork-placeholder">
<Disc3 size={28} />
</div>
{/if}
</div>
<div class="album-row-info">
<div class="album-row-title truncate">{album.title}</div>
<div class="album-row-meta">
<span>{album.artist.name}</span>
{#if getAlbumYear(album)}<span>{getAlbumYear(album)}</span>{/if}
</div>
</div>
<div class="album-row-quality">
<QualityBadge
bitDepth={album.maximum_bit_depth}
samplingRate={album.maximum_sampling_rate}
compact
/>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{#if albumGroupMode === 'alpha'}
<div class="alpha-index">
{#each alphaIndexLetters as letter}
<button
class="alpha-letter"
class:disabled={!alphaGroups.has(letter)}
onclick={() => scrollToGroup('album-alpha', letter, alphaGroups)}
>
{letter}
</button>
{/each}
</div>
{/if}
</div>
{:else}
{#if albumViewMode === 'grid'}
<div class="album-grid">
{#each filteredAlbums as album (album.id)}
<AlbumCard
albumId={album.id}
artwork={album.image?.large || album.image?.thumbnail || ''}
title={album.title}
artist={album.artist.name}
genre={getGenreLabel(album)}
releaseDate={album.release_date_original}
size="large"
quality={getQualityLabel(album)}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
isAlbumFullyDownloaded={isAlbumDownloaded(album.id)}
onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined}
onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined}
{downloadStateVersion}
onclick={() => { onAlbumClick?.(album.id); loadAlbumOfflineCacheStatus(album.id); }}
/>
{/each}
</div>
{:else}
<div class="album-list">
{#each filteredAlbums as album (album.id)}
<div class="album-row" role="button" tabindex="0" onclick={() => onAlbumClick?.(album.id)}>
<div class="album-row-art">
{#if album.image?.thumbnail || album.image?.small || album.image?.large}
<img src={album.image?.thumbnail || album.image?.small || album.image?.large} alt={album.title} loading="lazy" decoding="async" />
{:else}
<div class="artwork-placeholder">
<Disc3 size={28} />
</div>
{/if}
</div>
<div class="album-row-info">
<div class="album-row-title truncate">{album.title}</div>
<div class="album-row-meta">
<span>{album.artist.name}</span>
{#if getAlbumYear(album)}<span>{getAlbumYear(album)}</span>{/if}
</div>
</div>
<div class="album-row-quality">
<QualityBadge
bitDepth={album.maximum_bit_depth}
samplingRate={album.maximum_sampling_rate}
compact
/>
</div>
</div>
{/each}
</div>
{/if}
{/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 if filteredArtists.length === 0}
<div class="empty">
<Search size={48} />
<p>No artists match "{artistSearch}"</p>
</div>
{:else if artistGroupingEnabled}
{@const groupedArtists = groupArtists(filteredArtists)}
{@const artistAlphaGroups = new Set(groupedArtists.map(group => group.key))}
<div class="artist-sections">
<div class="artist-group-list">
{#each groupedArtists as group (group.id)}
<div class="artist-group" id={group.id}>
<div class="artist-group-header">
<span class="artist-group-title">{group.key}</span>
<span class="artist-group-count">{group.artists.length}</span>
</div>
<div class="artist-grid">
{#each group.artists as artist (artist.id)}
<button class="artist-card" onclick={() => onArtistClick?.(artist.id)}>
<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}
</button>
{/each}
</div>
</div>
{/each}
</div>
<div class="alpha-index">
{#each alphaIndexLetters as letter}
<button
class="alpha-letter"
class:disabled={!artistAlphaGroups.has(letter)}
onclick={() => scrollToGroup('artist-alpha', letter, artistAlphaGroups)}
>
{letter}
</button>
{/each}
</div>
</div>
{:else}
<div class="artist-grid">
{#each filteredArtists as artist (artist.id)}
<button class="artist-card" onclick={() => onArtistClick?.(artist.id)}>
<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}
</button>
{/each}
</div>
{/if}
{:else if activeTab === 'playlists'}
{#if loadingPlaylists}
<div class="loading">
<div class="spinner"></div>
<p>Loading playlists...</p>
</div>
{:else if favoritePlaylists.length === 0}
<div class="empty">
<ListMusic size={48} />
<p>No favorite playlists yet</p>
<p class="empty-hint">Click the heart icon on playlists to add them here</p>
</div>
{:else if filteredPlaylists.length === 0}
<div class="empty">
<Search size={48} />
<p>No playlists match "{playlistSearch}"</p>
</div>
{:else}
<div class="playlist-grid">
{#each filteredPlaylists as playlist (playlist.id)}
<button class="playlist-card" onclick={() => onPlaylistSelect?.(playlist.id)}>
<div class="playlist-artwork">
<PlaylistCollage artworks={playlist.images ?? []} size={140} />
</div>
<div class="playlist-name">{playlist.name}</div>
<div class="playlist-meta">{playlist.tracks_count} tracks</div>
</button>
{/each}
</div>
{/if}
{/if}
</div>
</div>
<FavoritesEditModal
isOpen={editModalOpen}
onClose={() => editModalOpen = false}
onSave={handlePreferencesSaved}
initialPreferences={favoritesPreferences}
/>
<style>
.favorites-view {
padding: 24px;
padding-left: 18px;
padding-right: 8px;
padding-bottom: 100px;
overflow-y: auto;
height: 100%;
}
/* Custom scrollbar */
.favorites-view::-webkit-scrollbar {
width: 6px;
}
.favorites-view::-webkit-scrollbar-track {
background: transparent;
}
.favorites-view::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 3px;
}
.favorites-view::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.header {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 32px;
position: relative;
}
.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;
overflow: hidden;
}
.custom-icon-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.header-content {
flex: 1;
}
.header-content h1 {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 4px 0;
}
.subtitle {
font-size: 14px;
color: var(--text-muted);
margin: 0;
}
.edit-btn {
position: absolute;
top: 0;
right: 0;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
transition: all 150ms ease;
}
.edit-btn:hover {
color: var(--accent-primary);
}
.favorites-nav {
position: sticky;
top: -24px;
z-index: 4;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 12px 24px;
margin: 0 -8px 16px -24px;
width: calc(100% + 32px);
background: var(--bg-primary);
border-bottom: 1px solid var(--alpha-6);
box-shadow: 0 4px 8px -4px rgba(0, 0, 0, 0.5);
}
.nav-left {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 20px;
}
.nav-link {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
border: none;
background: none;
color: var(--text-muted);
font-size: 13px;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 150ms ease, border-color 150ms ease;
}
.nav-link:hover {
color: var(--text-secondary);
}
.nav-link.active {
color: var(--text-primary);
border-bottom-color: var(--accent-primary);
}
.nav-right {
display: flex;
align-items: center;
gap: 8px;
}
.search-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: none;
color: var(--text-muted);
cursor: pointer;
border-radius: 6px;
transition: all 150ms ease;
}
.search-icon-btn:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.search-expanded {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--bg-secondary);
border: 1px solid var(--bg-tertiary);
border-radius: 8px;
min-width: 240px;
}
.search-icon-inline {
color: var(--text-muted);
flex-shrink: 0;
}
.search-input-inline {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-size: 13px;
}
.search-input-inline::placeholder {
color: var(--text-muted);
}
.search-clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
background: none;
color: var(--text-muted);
cursor: pointer;
border-radius: 4px;
transition: all 150ms ease;
flex-shrink: 0;
}
.search-clear-btn:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.search-clear-btn:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.toolbar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
.toolbar-controls {
display: flex;
align-items: center;
gap: 10px;
}
.dropdown-container {
position: relative;
z-index: 1000;
}
.control-btn {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
border-radius: 8px;
padding: 8px 12px;
font-size: 12px;
cursor: pointer;
}
.control-btn:hover {
color: var(--text-primary);
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: var(--bg-tertiary);
color: var(--text-secondary);
cursor: pointer;
}
.icon-btn:hover {
color: var(--text-primary);
}
.dropdown-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 170px;
background: var(--bg-secondary);
border: 1px solid var(--bg-tertiary);
border-radius: 8px;
padding: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
z-index: 100;
max-height: 260px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--text-muted) transparent;
}
.dropdown-menu::-webkit-scrollbar {
width: 8px;
}
.dropdown-menu::-webkit-scrollbar-track {
background: transparent;
}
.dropdown-menu::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 9999px;
}
.dropdown-menu::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
.dropdown-item {
width: 100%;
text-align: left;
padding: 8px 10px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
font-size: 12px;
}
.dropdown-item:hover,
.dropdown-item.selected {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.search-container {
display: flex;
align-items: center;
gap: 8px;
background-color: var(--bg-tertiary);
border-radius: 8px;
padding: 8px 12px;
flex: 1;
max-width: 300px;
}
.search-container :global(.search-icon) {
color: var(--text-muted);
flex-shrink: 0;
}
.search-input {
flex: 1;
background: none;
border: none;
color: var(--text-primary);
font-size: 14px;
outline: none;
}
.search-input::placeholder {
color: var(--text-muted);
}
.search-clear {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
justify-content: center;
}
.search-clear:hover {
color: var(--text-primary);
}
.results-count {
margin-left: auto;
font-size: 13px;
color: var(--text-muted);
}
.actions {
display: flex;
gap: 12px;
}
.play-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 24px;
background-color: var(--accent-primary);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 150ms ease;
}
.play-btn:hover {
background-color: var(--accent-hover);
}
.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;
}
.track-sections {
display: flex;
gap: 12px;
align-items: flex-start;
}
.track-sections.virtualized {
flex: 1;
height: calc(100vh - 380px);
min-height: 400px;
overflow: hidden;
}
.virtualized-container {
flex: 1;
height: 100%;
min-width: 0;
overflow: hidden;
}
/* Track list header - matches PlaylistDetailView style */
.track-list-header {
width: 100%;
height: 40px;
padding: 0 16px;
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
font-size: 12px;
text-transform: uppercase;
color: #666666;
font-weight: 400;
box-sizing: border-box;
border-bottom: 1px solid var(--bg-tertiary);
margin-bottom: 8px;
}
.track-list-header .col-number {
width: 48px;
text-align: center;
}
.track-list-header .col-title {
flex: 1;
min-width: 0;
}
.track-list-header .col-album {
flex: 1;
min-width: 0;
}
.track-list-header .col-duration {
width: 80px;
text-align: center;
}
.track-list-header .col-quality {
width: 80px;
text-align: center;
}
.track-list-header .col-icon {
width: 28px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
opacity: 0.5;
}
.track-list-header .col-spacer {
width: 28px;
}
.track-group-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 24px;
}
.track-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.track-group-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.track-group-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.track-group-subtitle {
font-size: 12px;
color: var(--text-muted);
}
.track-group-count {
font-size: 12px;
color: var(--text-muted);
}
.album-grid {
display: flex;
flex-wrap: wrap;
gap: 24px 14px;
}
.album-sections {
display: flex;
gap: 12px;
align-items: flex-start;
}
.album-group-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 24px;
}
.album-group-header {
display: flex;
align-items: baseline;
gap: 8px;
}
.album-group-title {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.album-group-count {
font-size: 12px;
color: var(--text-muted);
}
.album-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.album-row {
display: grid;
grid-template-columns: 56px 1fr auto;
gap: 12px;
align-items: center;
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 10px;
cursor: pointer;
transition: background 150ms ease;
}
.album-row:hover {
background: var(--bg-tertiary);
}
.album-row-art {
width: 52px;
height: 52px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.album-row-art img {
width: 100%;
height: 100%;
object-fit: cover;
}
.artwork-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-tertiary);
color: var(--text-muted);
}
.album-row-info {
min-width: 0;
}
.album-row-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
}
.album-row-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
font-size: 12px;
color: var(--text-muted);
}
.album-row-meta span + span::before {
content: "\2022";
margin: 0 8px;
color: var(--text-muted);
}
.album-row-quality {
display: flex;
justify-content: flex-end;
}
.artist-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 24px;
}
.artist-sections {
display: flex;
gap: 12px;
align-items: flex-start;
}
.artist-group-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 24px;
}
.artist-group-header {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--text-muted);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 8px;
}
.artist-group-title {
font-weight: 600;
}
.artist-group-count {
font-size: 12px;
}
.artist-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px;
background-color: var(--bg-secondary);
border: none;
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;
}
.alpha-index {
position: sticky;
top: 120px;
display: flex;
flex-direction: column;
gap: 6px;
padding: 6px 4px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.2);
}
.alpha-letter {
width: 20px;
height: 20px;
padding: 0;
background: none;
border: none;
color: var(--text-primary);
font-size: 11px;
font-weight: 600;
cursor: pointer;
opacity: 0.9;
}
.alpha-letter:hover {
color: var(--accent-primary);
}
.alpha-letter.disabled {
opacity: 0.25;
cursor: default;
pointer-events: none;
}
/* Playlist grid styles */
.playlist-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 24px;
}
.playlist-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px;
background-color: var(--bg-secondary);
border: none;
border-radius: 12px;
cursor: pointer;
transition: background-color 150ms ease;
}
.playlist-card:hover {
background-color: var(--bg-tertiary);
}
.playlist-artwork {
width: 140px;
height: 140px;
border-radius: 8px;
overflow: hidden;
margin-bottom: 12px;
}
.playlist-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
text-align: center;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
</style>