Files
qbz/src/lib/components/views/FavoritesView.svelte
T
vicrodh cec4c32bcc chore: remove unused CSS selectors from Svelte components
Clean up dead CSS that remained after HTML refactors:
- NowPlayingBar, Sidebar, PlaylistManagerView, FavoritesView
- PlaylistModal, LocalLibraryView, PlaylistDetailView
- Dropdown, MigrationModal, AlbumCard, QualityBadge
- SearchPlaylistCard, LocalLibraryTagEditorModal
- TrackReplacementModal, ImmersiveHeader, SuggestionsPanel
- LyricsFocusPanel, ExpandedPlayer, FocusMode
- ArtistDetailView, SearchView, SettingsView
2026-02-02 22:51:42 -06:00

3162 lines
101 KiB
Svelte

<script lang="ts">
import { invoke, convertFileSrc } from '@tauri-apps/api/core';
import { onMount, tick } from 'svelte';
import { get } from 'svelte/store';
import { t } from '$lib/i18n';
import { Heart, Play, Disc3, Mic2, Music, Search, X, LayoutGrid, List, ChevronDown, ListMusic, Edit3, Star, Folder, Library, CloudDownload, Shuffle, MoreHorizontal, PanelLeftClose, Loader2 } from 'lucide-svelte';
import AlbumCard from '../AlbumCard.svelte';
import TrackRow from '../TrackRow.svelte';
import QualityBadge from '../QualityBadge.svelte';
import VirtualizedTrackList from '../VirtualizedTrackList.svelte';
import FavoritePlaylistCard from '../FavoritePlaylistCard.svelte';
import FavoritesEditModal from '../FavoritesEditModal.svelte';
import ViewTransition from '../ViewTransition.svelte';
import { type OfflineCacheStatus } from '$lib/stores/offlineCacheState';
import { consumeContextTrackFocus, setPlaybackContext } from '$lib/stores/playbackContextStore';
import { normalizeFavoritesTabOrder } from '$lib/utils/favorites';
import { syncCache as syncTrackCache } from '$lib/stores/favoritesStore';
import { syncCache as syncAlbumCache } from '$lib/stores/albumFavoritesStore';
import { syncCache as syncArtistCache } from '$lib/stores/artistFavoritesStore';
import { categorizeAlbum, getQobuzImage, formatQuality } from '$lib/adapters/qobuzAdapters';
import GenreFilterButton from '../GenreFilterButton.svelte';
import {
hasActiveFilter as hasGenreFilter,
getFilterGenreNames,
type GenreFilterContext
} from '$lib/stores/genreFilterStore';
import type { FavoritesPreferences, QobuzAlbum } from '$lib/types';
const GENRE_CONTEXT: GenreFilterContext = 'favorites';
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;
onPlaylistPlay?: (playlistId: number) => void;
onPlaylistPlayNext?: (playlistId: number) => void;
onPlaylistPlayLater?: (playlistId: number) => void;
onPlaylistRemoveFavorite?: (playlistId: number) => void;
onPlaylistShareQobuz?: (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,
onPlaylistPlay,
onPlaylistPlayNext,
onPlaylistPlayLater,
onPlaylistRemoveFavorite,
onPlaylistShareQobuz,
selectedTab,
onTabNavigate,
activeTrackId = null,
isPlaybackActive = false
}: Props = $props();
type TabType = 'tracks' | 'albums' | 'artists' | 'playlists';
let activeTab = $state<TabType>('tracks');
let preferencesLoaded = $state(false);
function getTabTranslationKey(tab: TabType): string {
return `favorites.tabLabels.${tab}`;
}
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 spinnerFading = $state(false); // For spinner fadeout animation
let contentVisible = $state(false); // For content entrance animation
let editModalOpen = $state(false);
let scrollContainer: HTMLDivElement | null = $state(null);
let virtualizedTrackListRef: { scrollToGroup: (groupId: string) => void } | 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 AlbumSortBy = 'default' | 'date' | 'title' | 'artist';
type SortDirection = 'asc' | 'desc';
let albumSortBy = $state<AlbumSortBy>('default');
let albumSortDirection = $state<SortDirection>('desc'); // desc = newest first for date
let showAlbumSortMenu = $state(false);
const albumSortOptions: { value: AlbumSortBy; label: string }[] = [
{ value: 'default', label: 'Default' },
{ value: 'date', label: 'Recently Added' },
{ value: 'title', label: 'Title' },
{ value: 'artist', label: 'Artist' }
];
function selectAlbumSort(value: AlbumSortBy) {
if (albumSortBy === value && value !== 'default') {
albumSortDirection = albumSortDirection === 'asc' ? 'desc' : 'asc';
} else {
albumSortBy = value;
// Default directions
albumSortDirection = value === 'date' ? 'desc' : 'asc';
}
showAlbumSortMenu = 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);
// Artist view mode: grid (cards) or sidepanel (two-column with albums)
type ArtistViewMode = 'grid' | 'sidepanel';
let artistViewMode = $state<ArtistViewMode>('grid');
let selectedFavoriteArtist = $state<FavoriteArtist | null>(null);
let selectedArtistAlbums = $state<QobuzAlbum[]>([]);
let loadingArtistAlbums = $state(false);
let artistAlbumsError = $state<string | null>(null);
// Group albums by category: Discography, EPs & Singles, Live Albums
// Excludes: tributes, others (compilations, unofficial)
const groupedArtistAlbumsByCategory = $derived.by(() => {
if (!selectedFavoriteArtist || selectedArtistAlbums.length === 0) {
return { discography: [], epsSingles: [], liveAlbums: [] };
}
const discography: QobuzAlbum[] = [];
const epsSingles: QobuzAlbum[] = [];
const liveAlbums: QobuzAlbum[] = [];
for (const album of selectedArtistAlbums) {
const category = categorizeAlbum(album, selectedFavoriteArtist!.id);
switch (category) {
case 'albums':
discography.push(album);
break;
case 'eps':
epsSingles.push(album);
break;
case 'live':
liveAlbums.push(album);
break;
// 'tributes' and 'others' are excluded
}
}
return { discography, epsSingles, liveAlbums };
});
const totalDisplayedAlbums = $derived(
groupedArtistAlbumsByCategory.discography.length +
groupedArtistAlbumsByCategory.epsSingles.length +
groupedArtistAlbumsByCategory.liveAlbums.length
);
// Sorting for album sections in browse view
type AlbumSortMode = 'default' | 'newest' | 'oldest' | 'title-asc' | 'title-desc';
let discographySortMode = $state<AlbumSortMode>('default');
let epsSinglesSortMode = $state<AlbumSortMode>('default');
let liveAlbumsSortMode = $state<AlbumSortMode>('default');
let showDiscographySortMenu = $state(false);
let showEpsSinglesSortMenu = $state(false);
let showLiveAlbumsSortMenu = $state(false);
function sortQobuzAlbums(albums: QobuzAlbum[], mode: AlbumSortMode): QobuzAlbum[] {
if (mode === 'default') return albums;
return [...albums].sort((a, b) => {
switch (mode) {
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);
default:
return 0;
}
});
}
const sortedDiscography = $derived(sortQobuzAlbums(groupedArtistAlbumsByCategory.discography, discographySortMode));
const sortedEpsSingles = $derived(sortQobuzAlbums(groupedArtistAlbumsByCategory.epsSingles, epsSinglesSortMode));
const sortedLiveAlbums = $derived(sortQobuzAlbums(groupedArtistAlbumsByCategory.liveAlbums, liveAlbumsSortMode));
function getSortLabel(mode: AlbumSortMode): string {
const translate = get(t);
switch (mode) {
case 'default': return translate('sort.default');
case 'newest': return translate('sort.newest');
case 'oldest': return translate('sort.oldest');
case 'title-asc': return translate('sort.titleAsc');
case 'title-desc': return translate('sort.titleDesc');
}
}
// Ticker animation for long artist names in sidepanel
let hoveredArtistId = $state<number | null>(null);
let artistNameOverflows = $state<Map<number, number>>(new Map());
const tickerSpeed = 40; // px per second
function measureArtistNameOverflow(artistId: number, element: HTMLElement | null) {
if (!element) return;
const textSpan = element.querySelector('.artist-name-text') as HTMLElement | null;
if (!textSpan) return;
const overflow = textSpan.scrollWidth - element.clientWidth;
if (overflow > 0) {
artistNameOverflows.set(artistId, overflow);
} else {
artistNameOverflows.delete(artistId);
}
}
function getArtistNameTickerStyle(artistId: number): string {
if (hoveredArtistId !== artistId) return '';
const overflow = artistNameOverflows.get(artistId);
if (!overflow || overflow <= 0) return '';
const duration = (overflow + 16) / tickerSpeed;
return `--ticker-offset: -${overflow + 16}px; --ticker-duration: ${duration}s;`;
}
let showTracksContextMenu = $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]));
});
// Genre filter version to trigger reactivity
let genreFilterVersion = $state(0);
function handleGenreFilterChange() {
genreFilterVersion++;
}
let filteredAlbums = $derived.by(() => {
// Track genre filter changes for reactivity
void genreFilterVersion;
let albums = favoriteAlbums;
// Filter by genre (using favorites context)
// getFilterGenreNames returns selected genres + all children of selected parent genres
const filterGenreNames = getFilterGenreNames(GENRE_CONTEXT);
if (filterGenreNames.length > 0) {
albums = albums.filter(a =>
a.genre && filterGenreNames.some(genreName =>
a.genre!.name.toLowerCase().includes(genreName.toLowerCase())
)
);
}
// 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 (albumSortBy !== 'default') {
albums = [...albums].sort((a, b) => {
let cmp = 0;
switch (albumSortBy) {
case 'date': {
const dateA = a.release_date_original || '0000';
const dateB = b.release_date_original || '0000';
cmp = dateA.localeCompare(dateB);
break;
}
case 'title':
cmp = a.title.localeCompare(b.title);
break;
case 'artist':
cmp = a.artist.name.localeCompare(b.artist.name);
break;
}
return albumSortDirection === 'desc' ? -cmp : cmp;
});
}
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 get(t)(getTabTranslationKey(tab));
}
onMount(() => {
albumViewMode = loadStoredString('qbz-favorites-album-view', 'grid', ['grid', 'list']);
albumGroupMode = loadStoredString('qbz-favorites-album-group', 'alpha', ['alpha', 'artist']);
albumSortBy = loadStoredString('qbz-favorites-album-sort-by', 'default', ['default', 'date', 'title', 'artist']);
albumSortDirection = loadStoredString('qbz-favorites-album-sort-dir', 'desc', ['asc', 'desc']);
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);
artistViewMode = loadStoredString('qbz-favorites-artist-view-mode', 'grid', ['grid', 'sidepanel']) as ArtistViewMode;
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-by', albumSortBy);
localStorage.setItem('qbz-favorites-album-sort-dir', albumSortDirection);
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));
localStorage.setItem('qbz-favorites-artist-view-mode', artistViewMode);
} 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;
contentVisible = false;
spinnerFading = false;
error = null;
try {
const items = await fetchAllFavorites(type);
if (type === 'tracks') {
favoriteTracks = items as FavoriteTrack[];
// Sync to local cache for other views
const trackIds = favoriteTracks.map(t => t.id);
void syncTrackCache(trackIds);
} else if (type === 'albums') {
favoriteAlbums = items as FavoriteAlbum[];
await loadAllAlbumOfflineCacheStatuses(favoriteAlbums);
// Sync to local cache for other views
const albumIds = favoriteAlbums.map(a => a.id);
void syncAlbumCache(albumIds);
} else if (type === 'artists') {
favoriteArtists = items as FavoriteArtist[];
// Sync to local cache for other views
const artistIds = favoriteArtists.map(a => a.id);
void syncArtistCache(artistIds);
}
} catch (err) {
console.error(`Failed to load ${type} favorites:`, err);
error = String(err);
} finally {
// Trigger spinner fadeout, then show content
spinnerFading = true;
setTimeout(() => {
loading = false;
spinnerFading = false;
contentVisible = true;
}, 200); // Match fadeout duration
}
}
async function loadFavoritePlaylists() {
loadingPlaylists = true;
contentVisible = false;
spinnerFading = false;
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 {
// Trigger spinner fadeout, then show content
spinnerFading = true;
setTimeout(() => {
loadingPlaylists = false;
spinnerFading = false;
contentVisible = true;
}, 200); // Match fadeout duration
}
}
// Handle artist selection in sidepanel mode - fetch albums from Qobuz
async function handleArtistSelect(artist: FavoriteArtist) {
selectedFavoriteArtist = artist;
selectedArtistAlbums = [];
loadingArtistAlbums = true;
artistAlbumsError = null;
try {
const result = await invoke<{ items: QobuzAlbum[]; total: number }>('get_artist_albums', {
artistId: artist.id,
limit: 500, // Fetch more to ensure we have enough Discography albums after filtering
offset: 0
});
selectedArtistAlbums = result.items || [];
} catch (err) {
console.error('Failed to load artist albums:', err);
artistAlbumsError = String(err);
} finally {
loadingArtistAlbums = 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 scrollToTrackGroup(letter: string, trackAlphaGroups: Set<string>, trackIndexTargets: Map<string, string>) {
if (!trackAlphaGroups.has(letter)) return;
let groupId: string;
if (trackGroupMode === 'name') {
groupId = groupIdForKey('track-name', letter);
} else {
// 'artist' mode - use the pre-computed map
const targetId = trackIndexTargets.get(letter);
if (!targetId) return;
groupId = targetId;
}
virtualizedTrackListRef?.scrollToGroup(groupId);
}
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);
}
}
async function handleShuffleAllTracks() {
if (filteredTracks.length === 0 || !onTrackPlay) return;
try {
// Shuffle the tracks
const shuffled = [...filteredTracks].sort(() => Math.random() - 0.5);
const queueTracks = buildFavoritesQueueTracks(shuffled);
await invoke('set_queue', { tracks: queueTracks, startIndex: 0 });
await setFavoritesContext(shuffled.map(t => t.id), 0);
onTrackPlay(buildDisplayTrack(shuffled[0], 0));
} catch (err) {
console.error('Failed to shuffle queue:', err);
}
}
async function handlePlayAllTracksNext() {
if (filteredTracks.length === 0) return;
try {
const queueTracks = buildFavoritesQueueTracks(filteredTracks);
await invoke('add_to_queue_next', { tracks: queueTracks });
} catch (err) {
console.error('Failed to add tracks next:', err);
}
}
async function handlePlayAllTracksLater() {
if (filteredTracks.length === 0) return;
try {
const queueTracks = buildFavoritesQueueTracks(filteredTracks);
await invoke('add_to_queue', { tracks: queueTracks });
} catch (err) {
console.error('Failed to add tracks to queue:', err);
}
}
</script>
<ViewTransition duration={200} distance={12} direction="down">
<div class="favorites-view" class:no-outer-scroll={(activeTab === 'tracks' && !loading && filteredTracks.length > 0) || (activeTab === 'artists' && artistViewMode === 'sidepanel')} 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>{$t('favorites.title')}</h1>
{#if activeTab === 'tracks' && !loading && filteredTracks.length > 0}
<div class="header-actions">
<button class="action-btn-circle primary" onclick={handlePlayAllTracks} title={$t('actions.playAll')}>
<Play size={20} fill="currentColor" color="currentColor" />
</button>
<button class="action-btn-circle" onclick={handleShuffleAllTracks} title={$t('actions.shuffle')}>
<Shuffle size={18} />
</button>
<div class="context-menu-wrapper">
<button
class="action-btn-circle"
onclick={() => showTracksContextMenu = !showTracksContextMenu}
title={$t('actions.more')}
>
<MoreHorizontal size={18} />
</button>
{#if showTracksContextMenu}
<div class="context-menu-backdrop" onclick={() => showTracksContextMenu = false} role="presentation"></div>
<div class="context-menu">
<button class="context-menu-item" onclick={() => { handlePlayAllTracksNext(); showTracksContextMenu = false; }}>
{$t('actions.playNext')}
</button>
<button class="context-menu-item" onclick={() => { handlePlayAllTracksLater(); showTracksContextMenu = false; }}>
{$t('actions.addToQueue')}
</button>
</div>
{/if}
</div>
</div>
{/if}
</div>
<button class="edit-btn" onclick={() => editModalOpen = true} title={$t('favorites.editSettings')}>
<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={$t('nav.search')}>
<Search size={16} />
</button>
{:else}
<div class="search-expanded">
<Search size={16} class="search-icon-inline" />
<input
type="text"
placeholder={$t('placeholders.search')}
value={getCurrentSearchValue()}
oninput={(e) => setCurrentSearchValue(e.currentTarget.value)}
class="search-input-inline"
autofocus
/>
{#if getCurrentSearchValue()}
<button class="search-clear-btn" onclick={clearCurrentSearch} title={$t('actions.clear')}>
<X size={14} />
</button>
{/if}
</div>
{/if}
</div>
</div>
<!-- Toolbar with actions -->
<div class="toolbar">
{#if activeTab === 'albums'}
<div class="toolbar-controls">
<GenreFilterButton context={GENRE_CONTEXT} variant="control" align="right" onFilterChange={handleGenreFilterChange} />
<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>Sort: {albumSortOptions.find(o => o.value === albumSortBy)?.label}</span>
<ChevronDown size={14} />
</button>
{#if showAlbumSortMenu}
<div class="dropdown-menu">
{#each albumSortOptions as option}
<button
class="dropdown-item"
class:selected={albumSortBy === option.value}
onclick={() => selectAlbumSort(option.value)}
>
<span>{option.label}</span>
{#if albumSortBy === option.value && option.value !== 'default'}
<span class="sort-indicator">{albumSortDirection === 'asc' ? '↑' : '↓'}</span>
{/if}
</button>
{/each}
</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">
<!-- Single toggle button: Grid <-> Browse (sidepanel) -->
<button
class="control-btn icon-only"
onclick={() => {
if (artistViewMode === 'grid') {
artistViewMode = 'sidepanel';
} else {
artistViewMode = 'grid';
selectedFavoriteArtist = null;
}
}}
title={artistViewMode === 'grid' ? 'Browse view' : 'Grid view'}
>
{#if artistViewMode === 'grid'}
<PanelLeftClose size={16} />
{:else}
<LayoutGrid size={16} />
{/if}
</button>
{#if artistViewMode === 'grid'}
<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>
{/if}
</div>
{/if}
<!-- Alpha Index inline for artists in Browse (sidepanel) view -->
{#if activeTab === 'artists' && artistViewMode === 'sidepanel' && filteredArtists.length > 0}
{@const groupedArtistsForIndex = groupArtists(filteredArtists)}
{@const artistAlphaGroupsForIndex = new Set(groupedArtistsForIndex.map(group => group.key))}
<div class="alpha-index-inline">
{#each alphaIndexLetters as letter}
<button
class="alpha-letter"
class:disabled={!artistAlphaGroupsForIndex.has(letter)}
onclick={() => scrollToGroup('artist-alpha', letter, artistAlphaGroupsForIndex)}
>
{letter}
</button>
{/each}
</div>
{/if}
<!-- Alpha Index (inline in toolbar for tracks when grouping by name or artist) -->
{#if activeTab === 'tracks' && !loading && trackGroupingEnabled && (trackGroupMode === 'name' || trackGroupMode === 'artist')}
{@const groupedTracks = groupTracks(filteredTracks, trackGroupMode)}
{@const trackIndexTargets = trackGroupMode === 'artist'
? (() => {
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 = trackGroupMode === 'name'
? new Set(groupedTracks.map(group => group.key))
: new Set(trackIndexTargets.keys())}
<div class="alpha-index-inline">
{#each alphaIndexLetters as letter}
<button
class="alpha-letter"
class:disabled={!trackAlphaGroups.has(letter)}
onclick={() => scrollToTrackGroup(letter, trackAlphaGroups, trackIndexTargets)}
>
{letter}
</button>
{/each}
</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}
{#key activeTab}
<ViewTransition duration={200} distance={12} direction="down">
<div class="loading" class:fading={spinnerFading}>
<div class="spinner"></div>
<p>{$t('favorites.loadingFavorites')}</p>
</div>
</ViewTransition>
{/key}
{:else if error}
<div class="error">
<p>{$t('favorites.failedLoadFavorites')}</p>
<p class="error-detail">{error}</p>
<button class="retry-btn" onclick={() => loadTabIfNeeded(activeTab)}>{$t('actions.retry')}</button>
</div>
{:else if activeTab === 'tracks'}
<ViewTransition duration={200} distance={12} direction="up">
{#if favoriteTracks.length === 0}
<div class="empty">
<Music size={48} />
<p>{$t('favorites.noFavoriteTracks')}</p>
<p class="empty-hint">{$t('favorites.likeTracksHint')}</p>
</div>
{:else if filteredTracks.length === 0}
<div class="empty">
<Search size={48} />
<p>{$t('favorites.noTracksMatch', { values: { query: trackSearch } })}</p>
</div>
{:else}
{@const groupedTracks = trackGroupingEnabled
? groupTracks(filteredTracks, trackGroupMode)
: [{ key: '', id: 'ungrouped', title: '', tracks: filteredTracks }]}
<!-- Virtualized track list -->
<div class="track-sections virtualized">
<div class="virtualized-container">
<VirtualizedTrackList
bind:this={virtualizedTrackListRef}
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>
</div>
{/if}
</ViewTransition>
{:else if activeTab === 'albums'}
<ViewTransition duration={200} distance={12} direction="up">
{#if favoriteAlbums.length === 0}
<div class="empty">
<Disc3 size={48} />
<p>{$t('favorites.noFavoriteAlbums')}</p>
<p class="empty-hint">{$t('favorites.likeAlbumsHint')}</p>
</div>
{:else if filteredAlbums.length === 0}
<div class="empty">
<Search size={48} />
<p>{$t('favorites.noAlbumsMatch', { values: { query: 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}
</ViewTransition>
{:else if activeTab === 'artists'}
<ViewTransition duration={200} distance={12} direction="up">
{#if favoriteArtists.length === 0}
<div class="empty">
<Mic2 size={48} />
<p>{$t('favorites.noFavoriteArtists')}</p>
<p class="empty-hint">{$t('favorites.likeArtistsHint')}</p>
</div>
{:else if filteredArtists.length === 0}
<div class="empty">
<Search size={48} />
<p>{$t('favorites.noArtistsMatch', { values: { query: artistSearch } })}</p>
</div>
{:else if artistViewMode === 'sidepanel'}
<!-- Two-column sidepanel view -->
{@const groupedArtistsSidepanel = groupArtists(filteredArtists)}
<div class="artist-two-column-layout">
<!-- Left column: Artists list grouped A-Z -->
<div class="artist-column">
<div class="artist-list-scroll">
{#each groupedArtistsSidepanel as group (group.id)}
<div class="artist-list-group-header" id={group.id}>{group.key}</div>
{#each group.artists as artist (artist.id)}
{@const hasOverflow = artistNameOverflows.has(artist.id)}
{@const isHovered = hoveredArtistId === artist.id}
<button
class="artist-list-item"
class:selected={selectedFavoriteArtist?.id === artist.id}
onclick={() => handleArtistSelect(artist)}
onmouseenter={(e) => {
hoveredArtistId = artist.id;
const info = (e.currentTarget as HTMLElement).querySelector('.artist-list-name');
measureArtistNameOverflow(artist.id, info as HTMLElement);
}}
onmouseleave={() => { hoveredArtistId = null; }}
>
<div class="artist-list-image">
{#if artist.image?.thumbnail || artist.image?.small}
<img src={artist.image?.thumbnail || artist.image?.small} alt={artist.name} />
{:else}
<div class="artist-list-placeholder">
<Mic2 size={20} />
</div>
{/if}
</div>
<div class="artist-list-info">
<div
class="artist-list-name"
class:scrollable={hasOverflow}
style={getArtistNameTickerStyle(artist.id)}
>
<span class="artist-name-text" class:animating={isHovered && hasOverflow}>{artist.name}</span>
</div>
{#if artist.albums_count}
<div class="artist-list-meta">{$t('library.albumCount', { values: { count: artist.albums_count } })}</div>
{/if}
</div>
</button>
{/each}
{/each}
</div>
</div>
<!-- Right column: Selected artist's albums from Qobuz -->
<div class="artist-albums-column">
{#if !selectedFavoriteArtist}
<div class="artist-albums-empty">
<Mic2 size={48} />
<p>{$t('favorites.selectArtistHint')}</p>
</div>
{:else if loadingArtistAlbums}
<div class="artist-albums-loading">
<Loader2 size={32} class="spinner-icon" />
<p>{$t('favorites.loadingAlbums')}</p>
</div>
{:else if artistAlbumsError}
<div class="artist-albums-error">
<p>{$t('favorites.failedLoadAlbums')}</p>
<p class="error-detail">{artistAlbumsError}</p>
</div>
{:else if totalDisplayedAlbums === 0}
<div class="artist-albums-empty">
<Disc3 size={48} />
<p>{$t('favorites.noAlbumsFound')}</p>
</div>
{:else}
<div class="artist-albums-scroll">
<!-- Discography Section -->
{#if sortedDiscography.length > 0}
<div class="artist-albums-section">
<div class="artist-albums-section-header">
<span class="section-title">{$t('artist.discography')}</span>
<span class="section-count">{$t('library.albumCount', { values: { count: sortedDiscography.length } })}</span>
<div class="section-sort-wrapper">
<button
class="section-sort-btn"
onclick={() => { showDiscographySortMenu = !showDiscographySortMenu; }}
>
{getSortLabel(discographySortMode)}
<ChevronDown size={14} />
</button>
{#if showDiscographySortMenu}
<div class="section-sort-menu" role="menu">
{#each (['default', 'newest', 'oldest', 'title-asc', 'title-desc'] as const) as mode}
<button
class="section-sort-option"
class:selected={discographySortMode === mode}
onclick={() => { discographySortMode = mode; showDiscographySortMenu = false; }}
>
{getSortLabel(mode)}
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="artist-albums-grid">
{#each sortedDiscography as album (album.id)}
<AlbumCard
albumId={album.id}
artwork={getQobuzImage(album.image)}
title={album.title}
artist={album.artist.name}
genre={album.genre?.name}
releaseDate={album.release_date_original}
quality={formatQuality(album.hires_streamable, album.maximum_bit_depth, album.maximum_sampling_rate)}
onclick={() => onAlbumClick?.(album.id)}
onPlay={() => onAlbumPlay?.(album.id)}
onPlayNext={() => onAlbumPlayNext?.(album.id)}
onPlayLater={() => onAlbumPlayLater?.(album.id)}
onShareQobuz={() => onAlbumShareQobuz?.(album.id)}
onShareSonglink={() => onAlbumShareSonglink?.(album.id)}
onDownload={() => onAlbumDownload?.(album.id)}
/>
{/each}
</div>
</div>
{/if}
<!-- EPs & Singles Section -->
{#if sortedEpsSingles.length > 0}
<div class="artist-albums-section">
<div class="artist-albums-section-header">
<span class="section-title">{$t('artist.epsSingles')}</span>
<span class="section-count">{sortedEpsSingles.length}</span>
<div class="section-sort-wrapper">
<button
class="section-sort-btn"
onclick={() => { showEpsSinglesSortMenu = !showEpsSinglesSortMenu; }}
>
{getSortLabel(epsSinglesSortMode)}
<ChevronDown size={14} />
</button>
{#if showEpsSinglesSortMenu}
<div class="section-sort-menu" role="menu">
{#each (['default', 'newest', 'oldest', 'title-asc', 'title-desc'] as const) as mode}
<button
class="section-sort-option"
class:selected={epsSinglesSortMode === mode}
onclick={() => { epsSinglesSortMode = mode; showEpsSinglesSortMenu = false; }}
>
{getSortLabel(mode)}
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="artist-albums-grid">
{#each sortedEpsSingles as album (album.id)}
<AlbumCard
albumId={album.id}
artwork={getQobuzImage(album.image)}
title={album.title}
artist={album.artist.name}
genre={album.genre?.name}
releaseDate={album.release_date_original}
quality={formatQuality(album.hires_streamable, album.maximum_bit_depth, album.maximum_sampling_rate)}
onclick={() => onAlbumClick?.(album.id)}
onPlay={() => onAlbumPlay?.(album.id)}
onPlayNext={() => onAlbumPlayNext?.(album.id)}
onPlayLater={() => onAlbumPlayLater?.(album.id)}
onShareQobuz={() => onAlbumShareQobuz?.(album.id)}
onShareSonglink={() => onAlbumShareSonglink?.(album.id)}
onDownload={() => onAlbumDownload?.(album.id)}
/>
{/each}
</div>
</div>
{/if}
<!-- Live Albums Section -->
{#if sortedLiveAlbums.length > 0}
<div class="artist-albums-section">
<div class="artist-albums-section-header">
<span class="section-title">{$t('artist.liveAlbums')}</span>
<span class="section-count">{sortedLiveAlbums.length}</span>
<div class="section-sort-wrapper">
<button
class="section-sort-btn"
onclick={() => { showLiveAlbumsSortMenu = !showLiveAlbumsSortMenu; }}
>
{getSortLabel(liveAlbumsSortMode)}
<ChevronDown size={14} />
</button>
{#if showLiveAlbumsSortMenu}
<div class="section-sort-menu" role="menu">
{#each (['default', 'newest', 'oldest', 'title-asc', 'title-desc'] as const) as mode}
<button
class="section-sort-option"
class:selected={liveAlbumsSortMode === mode}
onclick={() => { liveAlbumsSortMode = mode; showLiveAlbumsSortMenu = false; }}
>
{getSortLabel(mode)}
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="artist-albums-grid">
{#each sortedLiveAlbums as album (album.id)}
<AlbumCard
albumId={album.id}
artwork={getQobuzImage(album.image)}
title={album.title}
artist={album.artist.name}
genre={album.genre?.name}
releaseDate={album.release_date_original}
quality={formatQuality(album.hires_streamable, album.maximum_bit_depth, album.maximum_sampling_rate)}
onclick={() => onAlbumClick?.(album.id)}
onPlay={() => onAlbumPlay?.(album.id)}
onPlayNext={() => onAlbumPlayNext?.(album.id)}
onPlayLater={() => onAlbumPlayLater?.(album.id)}
onShareQobuz={() => onAlbumShareQobuz?.(album.id)}
onShareSonglink={() => onAlbumShareSonglink?.(album.id)}
onDownload={() => onAlbumDownload?.(album.id)}
/>
{/each}
</div>
</div>
{/if}
<!-- Footer -->
<div class="artist-albums-footer">
<p class="footer-hint">
To view compilations and other content,
<button class="link-btn" onclick={() => onArtistClick?.(selectedFavoriteArtist!.id)}>
go to {selectedFavoriteArtist.name}'s page
</button>
</p>
</div>
</div>
{/if}
</div>
</div>
{:else if artistGroupingEnabled}
<!-- Grid view with grouping -->
{@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}
<!-- Grid view without grouping -->
<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}
</ViewTransition>
{:else if activeTab === 'playlists'}
{#if loadingPlaylists}
{#key activeTab}
<ViewTransition duration={200} distance={12} direction="down">
<div class="loading" class:fading={spinnerFading}>
<div class="spinner"></div>
<p>{$t('favorites.loadingPlaylists')}</p>
</div>
</ViewTransition>
{/key}
{:else}
<ViewTransition duration={200} distance={12} direction="up">
{#if favoritePlaylists.length === 0}
<div class="empty">
<ListMusic size={48} />
<p>{$t('favorites.noFavoritePlaylists')}</p>
<p class="empty-hint">{$t('favorites.likePlaylistsHint')}</p>
</div>
{:else if filteredPlaylists.length === 0}
<div class="empty">
<Search size={48} />
<p>{$t('favorites.noPlaylistsMatch', { values: { query: playlistSearch } })}</p>
</div>
{:else}
<div class="playlist-grid">
{#each filteredPlaylists as playlist (playlist.id)}
<FavoritePlaylistCard
{playlist}
onclick={() => onPlaylistSelect?.(playlist.id)}
onPlay={() => onPlaylistPlay?.(playlist.id)}
onPlayNext={() => onPlaylistPlayNext?.(playlist.id)}
onPlayLater={() => onPlaylistPlayLater?.(playlist.id)}
onRemoveFavorite={() => onPlaylistRemoveFavorite?.(playlist.id)}
onShareQobuz={() => onPlaylistShareQobuz?.(playlist.id)}
/>
{/each}
</div>
{/if}
</ViewTransition>
{/if}
{/if}
</div>
</div>
</ViewTransition>
<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);
}
.favorites-view.no-outer-scroll {
overflow: hidden;
padding-bottom: 0;
display: flex;
flex-direction: column;
}
.favorites-view.no-outer-scroll .content {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.favorites-view.no-outer-scroll .content > :global(.view-transition) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.header {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 16px;
position: relative;
}
.header-icon {
width: 94px;
height: 94px;
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;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
margin-top: 12px;
}
.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: 10px 24px;
margin: 0 -8px 12px -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-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 {
background: var(--bg-hover);
color: var(--text-primary);
}
.control-btn.icon-only {
width: 36px;
height: 36px;
justify-content: center;
padding: 0;
}
.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 {
display: flex;
align-items: center;
justify-content: space-between;
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);
}
.sort-indicator {
font-size: 11px;
color: var(--accent-primary);
font-weight: 600;
}
.results-count {
margin-left: auto;
font-size: 13px;
color: var(--text-muted);
}
.context-menu-wrapper {
position: relative;
}
.context-menu-backdrop {
position: fixed;
inset: 0;
z-index: 99;
}
.context-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
min-width: 160px;
background-color: var(--bg-tertiary);
border-radius: 8px;
padding: 2px 0;
z-index: 100;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.context-menu-item {
display: block;
width: 100%;
padding: 8px 12px;
background: none;
border: none;
text-align: left;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
transition: background-color 150ms ease, color 150ms ease;
}
.context-menu-item:hover {
background-color: var(--bg-hover);
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);
}
.loading {
opacity: 1;
transition: opacity 200ms ease-out;
}
.loading.fading {
opacity: 0;
}
.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-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;
}
.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-index-inline {
display: flex;
flex-wrap: wrap;
gap: 2px;
flex: 1;
justify-content: center;
}
.alpha-index-inline .alpha-letter {
width: 22px;
height: 22px;
font-size: 10px;
}
.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, 140px);
gap: 24px;
justify-content: start;
}
/* Artist Two-Column Sidepanel Layout */
.artist-two-column-layout {
display: flex;
gap: 0;
flex: 1;
min-height: 0;
margin: 0 -8px 0 -18px; /* Negative margins to extend to edges (match parent padding) */
padding: 0;
}
.artist-column {
width: 240px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: transparent;
border-right: 1px solid var(--bg-tertiary);
overflow: hidden;
padding-left: 18px;
}
.artist-list-scroll {
flex: 1;
overflow-y: auto;
padding: 4px 12px 4px 0;
/* Smooth scrolling optimizations */
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
overscroll-behavior: contain;
will-change: scroll-position;
contain: strict;
}
.artist-list-group-header {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
padding: 12px 8px 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.artist-list-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 8px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
transition: background-color 150ms ease;
text-align: left;
}
.artist-list-item:hover {
background: var(--bg-hover);
}
.artist-list-item.selected {
background: var(--bg-tertiary);
}
.artist-list-image {
width: 48px;
height: 48px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
background: var(--bg-tertiary);
}
.artist-list-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.artist-list-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.artist-list-info {
flex: 1;
min-width: 0;
}
.artist-list-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.artist-list-name.scrollable {
text-overflow: clip;
}
.artist-list-name .artist-name-text {
display: inline-block;
}
.artist-list-name .artist-name-text.animating {
animation: artist-name-ticker var(--ticker-duration, 0s) linear infinite;
}
@keyframes artist-name-ticker {
0%, 20% { transform: translateX(0); }
70%, 80% { transform: translateX(var(--ticker-offset, 0)); }
90%, 100% { transform: translateX(0); }
}
.artist-list-meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
.artist-albums-column {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
padding: 0 8px 0 24px;
}
.artist-albums-scroll {
flex: 1;
overflow-y: auto;
padding-right: 8px;
/* Smooth scrolling optimizations */
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
contain: strict;
}
.artist-albums-section {
margin-bottom: 32px;
}
.artist-albums-section:last-of-type {
margin-bottom: 16px;
}
.artist-albums-section-header {
display: flex;
align-items: baseline;
gap: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--bg-tertiary);
margin-bottom: 16px;
}
.artist-albums-section-header .section-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.artist-albums-section-header .section-count {
font-size: 13px;
color: var(--text-muted);
}
.section-sort-wrapper {
position: relative;
margin-left: auto;
}
.section-sort-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border: none;
background: transparent;
border-radius: 4px;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: background-color 150ms ease, color 150ms ease;
}
.section-sort-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.section-sort-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
min-width: 120px;
background: var(--bg-secondary);
border: 1px solid var(--bg-tertiary);
border-radius: 8px;
padding: 4px;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.section-sort-option {
display: block;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
border-radius: 4px;
color: var(--text-secondary);
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background-color 150ms ease, color 150ms ease;
}
.section-sort-option:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.section-sort-option.selected {
color: var(--accent);
font-weight: 500;
}
.artist-albums-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
align-content: start;
}
.artist-albums-empty,
.artist-albums-loading,
.artist-albums-error {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-muted);
}
.artist-albums-loading :global(.spinner-icon) {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.artist-albums-error {
color: var(--danger);
}
.artist-albums-error .error-detail {
font-size: 12px;
color: var(--text-muted);
}
.artist-albums-footer {
padding: 24px 0 16px;
text-align: center;
}
.artist-albums-footer .footer-hint {
font-size: 13px;
color: var(--text-muted);
margin: 0;
}
.artist-albums-footer .link-btn {
background: none;
border: none;
padding: 0;
color: var(--accent-primary);
cursor: pointer;
font-size: inherit;
text-decoration: underline;
text-decoration-color: transparent;
transition: text-decoration-color 150ms ease;
}
.artist-albums-footer .link-btn:hover {
text-decoration-color: var(--accent-primary);
}
</style>