diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 16186a68..9791fd89 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2 } from 'lucide-react'; +import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack } from 'lucide-react'; import LastfmIcon from './LastfmIcon'; import StarRating from './StarRating'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm'; @@ -1535,6 +1535,105 @@ export default function ContextMenu() { ); })()} + {type === 'favorite-song' && (() => { + const song = item as Track; + return ( + <> +
handleAction(() => playTrack(song, [song]))}> + {t('contextMenu.playNow')} +
+
handleAction(() => { + if (!currentTrack) { + playTrack(song, [song]); + return; + } + const currentIdx = usePlayerStore.getState().queueIndex; + const newQueue = [...queue]; + newQueue.splice(currentIdx + 1, 0, song); + usePlayerStore.setState({ queue: newQueue }); + })}> + {t('contextMenu.playNext')} +
+
handleAction(() => enqueue([song]))}> + {t('contextMenu.addToQueue')} +
+
{ setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={() => setPlaylistSubmenuOpen(false)} + > + {t('contextMenu.addToPlaylist')} + + {playlistSubmenuOpen && playlistSongIds[0] === song.id && ( + { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> + )} +
+
+ {song.albumId && ( +
handleAction(() => navigate(`/album/${song.albumId}`))}> + {t('contextMenu.openAlbum')} +
+ )} + {song.artistId && ( +
handleAction(() => navigate(`/artist/${song.artistId}`))}> + {t('contextMenu.goToArtist')} +
+ )} +
handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}> + {t('contextMenu.startRadio')} +
+ {audiomuseNavidromeEnabled && ( +
handleAction(() => startInstantMix(song))}> + {t('contextMenu.instantMix')} +
+ )} + {auth.lastfmSessionKey && (() => { + const loveKey = `${song.title}::${song.artist}`; + const loved = lastfmLovedCache[loveKey] ?? false; + return ( +
handleAction(() => { + const newLoved = !loved; + setLastfmLovedForSong(song.title, song.artist, newLoved); + if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey); + else lastfmUnloveTrack(song, auth.lastfmSessionKey); + })}> + + {loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')} +
+ ); + })()} +
e.stopPropagation()} + > + + { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }} + ariaLabel={t('albumDetail.ratingLabel')} + /> +
+
+
handleAction(() => openSongInfo(song.id))}> + {t('contextMenu.songInfo')} +
+
+
handleAction(() => { + setStarredOverride(song.id, false); + return unstar(song.id, 'song'); + })}> + {t('contextMenu.unfavorite')} +
+ + ); + })()} + {type === 'album' && (() => { const album = item as SubsonicAlbum; const albumRatingDisabled = entityRatingSupport === 'track_only'; diff --git a/src/locales/de.ts b/src/locales/de.ts index fa3a29b6..d7b04ab2 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -211,6 +211,11 @@ export const deTranslation = { playAll: 'Alle abspielen', removeSong: 'Aus Favoriten entfernen', stations: 'Radiosender', + showingFiltered: 'Zeige {{filtered}} von {{total}} ({{artist}})', + showingCount: 'Zeige {{filtered}} von {{total}}', + clearArtistFilter: 'Künstlerfilter zurücksetzen', + noFilterResults: 'Keine Ergebnisse mit den ausgewählten Filtern.', + allArtists: 'Alle Künstler', }, randomLanding: { title: 'Mix erstellen', @@ -399,6 +404,10 @@ export const deTranslation = { durationHoursMinutes: '{{hours}} Std. {{minutes}} Min.', durationMinutesOnly: '{{minutes}} Min.', updaterOpenGitHub: 'Auf GitHub öffnen', + filters: 'Filter', + more: 'mehr', + yearRange: 'Jahresbereich', + clearAll: 'Alles zurücksetzen', }, settings: { title: 'Einstellungen', diff --git a/src/locales/en.ts b/src/locales/en.ts index 0709d94d..d4de77cd 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -212,6 +212,11 @@ export const enTranslation = { playAll: 'Play all', removeSong: 'Remove from favorites', stations: 'Radio Stations', + showingFiltered: 'Showing {{filtered}} of {{total}} ({{artist}})', + showingCount: 'Showing {{filtered}} of {{total}}', + clearArtistFilter: 'Clear artist filter', + noFilterResults: 'No results with selected filters.', + allArtists: 'All Artists', }, randomLanding: { title: 'Build a Mix', @@ -400,6 +405,10 @@ export const enTranslation = { durationHoursMinutes: '{{hours}}h {{minutes}}m', durationMinutesOnly: '{{minutes}}m', updaterOpenGitHub: 'Open on GitHub', + filters: 'Filters', + more: 'more', + yearRange: 'Year Range', + clearAll: 'Clear all', }, settings: { title: 'Settings', diff --git a/src/locales/es.ts b/src/locales/es.ts index c44a9f50..743c8132 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -212,6 +212,11 @@ export const esTranslation = { playAll: 'Reproducir todo', removeSong: 'Quitar de favoritos', stations: 'Estaciones de Radio', + showingFiltered: 'Mostrando {{filtered}} de {{total}} ({{artist}})', + showingCount: 'Mostrando {{filtered}} de {{total}}', + clearArtistFilter: 'Limpiar filtro de artista', + noFilterResults: 'No hay resultados con los filtros seleccionados.', + allArtists: 'Todos los Artistas', }, randomLanding: { title: 'Crear Mezcla', @@ -401,6 +406,10 @@ export const esTranslation = { durationHoursMinutes: '{{hours}}h {{minutes}}m', durationMinutesOnly: '{{minutes}}m', updaterOpenGitHub: 'Abrir en GitHub', + filters: 'Filtros', + more: 'más', + yearRange: 'Rango de años', + clearAll: 'Limpiar todo', }, settings: { title: 'Configuración', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index b523a851..b5fa9858 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -211,6 +211,11 @@ export const frTranslation = { playAll: 'Tout lire', removeSong: 'Retirer des favoris', stations: 'Stations de radio', + showingFiltered: 'Affichage de {{filtered}} sur {{total}} ({{artist}})', + showingCount: 'Affichage de {{filtered}} sur {{total}}', + clearArtistFilter: 'Effacer le filtre artiste', + noFilterResults: 'Aucun résultat avec les filtres sélectionnés.', + allArtists: 'Tous les artistes', }, randomLanding: { title: 'Créer un mix', @@ -399,6 +404,10 @@ export const frTranslation = { durationHoursMinutes: '{{hours}} h {{minutes}} min', durationMinutesOnly: '{{minutes}} min', updaterOpenGitHub: 'Ouvrir sur GitHub', + filters: 'Filtres', + more: 'plus', + yearRange: 'Plage d\'années', + clearAll: 'Tout effacer', }, settings: { title: 'Paramètres', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 4f0912a1..0a3b13e3 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -210,7 +210,12 @@ export const nbTranslation = { enqueueAll: 'Legg alle i kø', playAll: 'Spill alle', removeSong: 'Fjern fra favoritter', - stations: 'Radiostasjoner', + stations: 'Radiostasjoner', + showingFiltered: 'Viser {{filtered}} av {{total}} ({{artist}})', + showingCount: 'Viser {{filtered}} av {{total}}', + clearArtistFilter: 'Tøm artistfilter', + noFilterResults: 'Ingen resultater med valgte filtre.', + allArtists: 'Alle artister', }, randomLanding: { title: 'Lag en miks', @@ -399,6 +404,10 @@ export const nbTranslation = { durationHoursMinutes: '{{hours}} t {{minutes}} min', durationMinutesOnly: '{{minutes}} min', updaterOpenGitHub: 'Åpne på GitHub', + filters: 'Filter', + more: 'mer', + yearRange: 'Årsspenn', + clearAll: 'Tøm alt', }, settings: { title: 'Innstillinger', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index cee50b3c..2af79694 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -210,6 +210,11 @@ export const nlTranslation = { playAll: 'Alles afspelen', removeSong: 'Verwijderen uit favorieten', stations: 'Radiostations', + showingFiltered: 'Toont {{filtered}} van {{total}} ({{artist}})', + showingCount: 'Toont {{filtered}} van {{total}}', + clearArtistFilter: 'Artiestfilter wissen', + noFilterResults: 'Geen resultaten met de geselecteerde filters.', + allArtists: 'Alle artiesten', }, randomLanding: { title: 'Mix samenstellen', @@ -398,6 +403,10 @@ export const nlTranslation = { durationHoursMinutes: '{{hours}} u {{minutes}} min', durationMinutesOnly: '{{minutes}} min', updaterOpenGitHub: 'Openen op GitHub', + filters: 'Filters', + more: 'meer', + yearRange: 'Jaarbereik', + clearAll: 'Alles wissen', }, settings: { title: 'Instellingen', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index f6c69cb3..6619c3df 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -215,6 +215,11 @@ export const ruTranslation = { playAll: 'Воспроизвести всё', removeSong: 'Убрать из избранного', stations: 'Радиостанции', + showingFiltered: 'Показано {{filtered}} из {{total}} ({{artist}})', + showingCount: 'Показано {{filtered}} из {{total}}', + clearArtistFilter: 'Сбросить фильтр исполнителя', + noFilterResults: 'Нет результатов с выбранными фильтрами.', + allArtists: 'Все исполнители', }, randomLanding: { title: 'Собрать микс', @@ -413,6 +418,10 @@ export const ruTranslation = { durationHoursMinutes: '{{hours}}ч {{minutes}}мин', durationMinutesOnly: '{{minutes}}мин', updaterOpenGitHub: 'Открыть на GitHub', + filters: 'Фильтры', + more: 'еще', + yearRange: 'Диапазон лет', + clearAll: 'Очистить всё', }, settings: { title: 'Настройки', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index ffb8cd6d..9044107b 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -210,6 +210,11 @@ export const zhTranslation = { playAll: '全部播放', removeSong: '从收藏中移除', stations: '广播电台', + showingFiltered: '显示 {{filtered}} / {{total}} ({{artist}})', + showingCount: '显示 {{filtered}} / {{total}}', + clearArtistFilter: '清除艺术家筛选', + noFilterResults: '所选筛选条件下无结果。', + allArtists: '全部艺术家', }, randomLanding: { title: '创建混音', @@ -394,6 +399,10 @@ export const zhTranslation = { durationHoursMinutes: '{{hours}}小时{{minutes}}分钟', durationMinutesOnly: '{{minutes}}分钟', updaterOpenGitHub: '在 GitHub 上打开', + filters: '筛选器', + more: '更多', + yearRange: '年份范围', + clearAll: '清除全部', }, settings: { title: '设置', diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx index bf2de86d..20fdead2 100644 --- a/src/pages/Favorites.tsx +++ b/src/pages/Favorites.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns'; import AlbumRow from '../components/AlbumRow'; import ArtistRow from '../components/ArtistRow'; @@ -10,7 +10,7 @@ import { } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import StarRating from '../components/StarRating'; -import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, X } from 'lucide-react'; +import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, X, SlidersHorizontal, ArrowUp, ArrowDown } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { unstar } from '../api/subsonic'; @@ -18,16 +18,25 @@ import { useDragDrop } from '../contexts/DragDropContext'; import { useAuthStore } from '../store/authStore'; import { useSelectionStore } from '../store/selectionStore'; import { AddToPlaylistSubmenu } from '../components/ContextMenu'; +import GenreFilterBar from '../components/GenreFilterBar'; const FAV_COLUMNS: readonly ColDef[] = [ { key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true }, { key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true }, { key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false }, + { key: 'album', i18nKey: 'trackAlbum', minWidth: 80, defaultWidth: 180, required: false }, { key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false }, { key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false }, + { key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 80, required: false }, { key: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true }, ]; +const CURRENT_YEAR = new Date().getFullYear(); +const MIN_YEAR = 1950; + +// Columns that support 3-state sorting (asc → desc → reset) +const SORTABLE_COLUMNS = new Set(['title', 'artist', 'album', 'rating', 'duration']); + export default function Favorites() { const { t } = useTranslation(); const [albums, setAlbums] = useState([]); @@ -36,6 +45,21 @@ export default function Favorites() { const [radioStations, setRadioStations] = useState([]); const [loading, setLoading] = useState(true); + // ── Sorting (3-state: asc → desc → reset) ──────────────────────────────── + const [sortKey, setSortKey] = useState('natural'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); + const [sortClickCount, setSortClickCount] = useState(0); + + // ── Artist filtering ───────────────────────────────────────────────────── + const [selectedArtist, setSelectedArtist] = useState(null); + + // ── Genre filtering ────────────────────────────────────────────────────── + const [selectedGenres, setSelectedGenres] = useState([]); + + // ── Year range filtering ───────────────────────────────────────────────── + const [yearRange, setYearRange] = useState<[number, number]>([MIN_YEAR, CURRENT_YEAR]); + const [showFilters, setShowFilters] = useState(false); + // ── Column resize/visibility (must be before early return) ─────────────── const { colVisible, visibleCols, gridStyle, @@ -75,6 +99,36 @@ export default function Favorites() { setSongs(prev => prev.filter(s => s.id !== id)); } + // ── Sorting logic ───────────────────────────────────────────────────────── + const handleSortClick = (key: string) => { + if (!SORTABLE_COLUMNS.has(key)) return; + + if (sortKey === key) { + const nextCount = sortClickCount + 1; + if (nextCount >= 3) { + // Reset to natural order (favorite addition order) + setSortKey('natural'); + setSortDir('asc'); + setSortClickCount(0); + } else { + // Toggle direction + setSortDir(d => d === 'asc' ? 'desc' : 'asc'); + setSortClickCount(nextCount); + } + } else { + // Start new sort on this column + setSortKey(key); + setSortDir('asc'); + setSortClickCount(1); + } + }; + + const getSortIndicator = (key: string) => { + if (sortKey !== key) return null; + if (sortClickCount === 0) return null; + return sortDir === 'asc' ? : ; + }; + function unfavoriteStation(id: string) { setRadioStations(prev => prev.filter(s => s.id !== id)); try { @@ -151,6 +205,68 @@ export default function Favorites() { loadAll(); }, [musicLibraryFilterVersion]); + // ── Filter & sort logic ────────────────────────────────────────────────── + const filteredSongs = useMemo(() => { + return songs.filter(s => { + // Remove unfavorited + if (starredOverrides[s.id] === false) return false; + + // Artist filter + if (selectedArtist) { + const artistMatch = s.artistId === selectedArtist || + s.artist === selectedArtist || + s.albumArtist === selectedArtist; + if (!artistMatch) return false; + } + + // Genre filter + if (selectedGenres.length > 0) { + const songGenre = s.genre || ''; + const hasMatchingGenre = selectedGenres.some(g => + songGenre.toLowerCase().includes(g.toLowerCase()) + ); + if (!hasMatchingGenre) return false; + } + + // Year range filter + if (s.year !== undefined) { + if (s.year < yearRange[0] || s.year > yearRange[1]) return false; + } + + return true; + }); + }, [songs, starredOverrides, selectedArtist, selectedGenres, yearRange]); + + // ── Sort logic ─────────────────────────────────────────────────────────── + const visibleSongs = useMemo(() => { + if (sortKey === 'natural' || sortClickCount === 0) { + return filteredSongs; + } + + const sorted = [...filteredSongs]; + const multiplier = sortDir === 'asc' ? 1 : -1; + + return sorted.sort((a, b) => { + switch (sortKey) { + case 'title': + return multiplier * (a.title || '').localeCompare(b.title || ''); + case 'artist': + return multiplier * ((a.artist || '').localeCompare(b.artist || '')); + case 'album': + return multiplier * ((a.album || '').localeCompare(b.album || '')); + case 'rating': + const ratingA = ratings[a.id] ?? userRatingOverrides[a.id] ?? a.userRating ?? 0; + const ratingB = ratings[b.id] ?? userRatingOverrides[b.id] ?? b.userRating ?? 0; + return multiplier * (ratingA - ratingB); + case 'duration': + return multiplier * ((a.duration || 0) - (b.duration || 0)); + default: + return 0; + } + }); + }, [filteredSongs, sortKey, sortDir, sortClickCount, ratings, userRatingOverrides]); + + if (loading) { return (
@@ -158,9 +274,8 @@ export default function Favorites() {
); } - - const visibleSongs = songs.filter(s => starredOverrides[s.id] !== false); - const hasAnyFavorites = albums.length > 0 || artists.length > 0 || visibleSongs.length > 0 || radioStations.length > 0; + // Check if user has any favorites (using original unfiltered lists) + const hasAnyFavorites = albums.length > 0 || artists.length > 0 || songs.length > 0 || radioStations.length > 0; return (
@@ -194,30 +309,130 @@ export default function Favorites() { /> )} - {visibleSongs.length > 0 && ( + {(visibleSongs.length > 0 || selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && (
-
-

{t('favorites.songs')}

- - + {/* ── Section Header with Stats & Filters ───────────────────────── */} +
+ {/* Title Row with showing X of Y indicator */} +
+

{t('favorites.songs')}

+ + {selectedArtist + ? t('favorites.showingFiltered', { filtered: visibleSongs.length, total: songs.filter(s => starredOverrides[s.id] !== false).length, artist: selectedArtist }) + : t('favorites.showingCount', { filtered: visibleSongs.length, total: songs.filter(s => starredOverrides[s.id] !== false).length })} + +
+ + {/* Action Buttons */} +
+ + + + {/* Filter Toggle Button */} + + + {(selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && ( + + )} +
+ + {/* Filters Panel */} + {showFilters && ( +
+
+ +
+ + {/* Year Range Filter */} +
+
+ {t('common.yearRange')}: + {yearRange[0]} - {yearRange[1]} +
+
+ { + const val = parseInt(e.target.value); + setYearRange(prev => [Math.min(val, prev[1] - 1), prev[1]]); + }} + style={{ flex: 1 }} + /> + { + const val = parseInt(e.target.value); + setYearRange(prev => [prev[0], Math.max(val, prev[0] + 1)]); + }} + style={{ flex: 1 }} + /> +
+
+
+ )} + + {selectedArtist && ( + + )}
{ if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll(); @@ -283,17 +498,34 @@ export default function Favorites() { } if (key === 'title') { const hasNextCol = colIndex + 1 < visibleCols.length; + const canSort = SORTABLE_COLUMNS.has('title'); return (
-
+
handleSortClick('title')} + > {label} + {canSort && getSortIndicator('title')}
{hasNextCol &&
startResize(e, colIndex + 1, -1)} />}
); } if (key === 'remove') return
; + const isCentered = key === 'duration' || key === 'rating'; + const canSort = SORTABLE_COLUMNS.has(key); + return (
canSort && handleSortClick(key)} > {label} + {canSort && getSortIndicator(key)}
{!isLastCol &&
startResize(e, colIndex, 1)} />}
@@ -352,7 +588,7 @@ export default function Favorites() { playTrack(track, visibleSongs.map(songToTrack)); } }} - onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }} + onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'favorite-song'); }} role="row" onMouseDown={e => { if (e.button !== 0) return; @@ -392,6 +628,34 @@ export default function Favorites() { song.artistId && navigate(`/artist/${song.artistId}`)}>{song.artist}
); + case 'album': return ( +
+ {song.albumId ? ( + { + e.stopPropagation(); + navigate(`/album/${song.albumId}`); + }} + > + {song.album} + + ) : ( + {song.album} + )} +
+ ); + case 'format': return ( +
+ {(song.suffix || song.bitRate) && ( + + {song.suffix?.toUpperCase()} + {song.suffix && song.bitRate && ' · '} + {song.bitRate && `${song.bitRate} kbps`} + + )} +
+ ); case 'rating': return ( ); })} + + {/* Empty state when filters return no results */} + {visibleSongs.length === 0 && (selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && ( +
+ {t('favorites.noFilterResults')} +
+ )}
+
)} diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 6aaf6185..eb0c8bcc 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -174,12 +174,12 @@ interface PlayerState { x: number; y: number; item: any; - type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist' | null; + type: 'song' | 'favorite-song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist' | null; queueIndex?: number; playlistId?: string; playlistSongIndex?: number; }; - openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist', queueIndex?: number, playlistId?: string, playlistSongIndex?: number) => void; + openContextMenu: (x: number, y: number, item: any, type: 'song' | 'favorite-song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist', queueIndex?: number, playlistId?: string, playlistSongIndex?: number) => void; closeContextMenu: () => void; songInfoModal: { isOpen: boolean; songId: string | null };