mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
Merge pull request #184 from kveld9/feat/ux-redesign-favorites
feat: ux redesign favorites -- sortable columns, gender filter, age range filter, new columns.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
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 LastfmIcon from './LastfmIcon';
|
||||||
import StarRating from './StarRating';
|
import StarRating from './StarRating';
|
||||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
||||||
@@ -1535,6 +1535,105 @@ export default function ContextMenu() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{type === 'favorite-song' && (() => {
|
||||||
|
const song = item as Track;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, [song]))}>
|
||||||
|
<Play size={14} /> {t('contextMenu.playNow')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-item" onClick={() => 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 });
|
||||||
|
})}>
|
||||||
|
<ChevronRight size={14} /> {t('contextMenu.playNext')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
|
||||||
|
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||||
|
data-playlist-trigger-id={song.id}
|
||||||
|
onMouseEnter={() => { setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||||
|
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||||
|
>
|
||||||
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
|
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||||
|
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
{song.albumId && (
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${song.albumId}`))}>
|
||||||
|
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{song.artistId && (
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${song.artistId}`))}>
|
||||||
|
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
|
||||||
|
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||||
|
</div>
|
||||||
|
{audiomuseNavidromeEnabled && (
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => startInstantMix(song))}>
|
||||||
|
<Sparkles size={14} /> {t('contextMenu.instantMix')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{auth.lastfmSessionKey && (() => {
|
||||||
|
const loveKey = `${song.title}::${song.artist}`;
|
||||||
|
const loved = lastfmLovedCache[loveKey] ?? false;
|
||||||
|
return (
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
|
const newLoved = !loved;
|
||||||
|
setLastfmLovedForSong(song.title, song.artist, newLoved);
|
||||||
|
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
|
||||||
|
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
|
||||||
|
})}>
|
||||||
|
<LastfmIcon size={14} />
|
||||||
|
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<div
|
||||||
|
className="context-menu-rating-row"
|
||||||
|
data-rating-kind="song"
|
||||||
|
data-rating-id={song.id}
|
||||||
|
data-rating-disabled="false"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||||
|
<StarRating
|
||||||
|
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
|
||||||
|
? keyboardRating.value
|
||||||
|
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||||
|
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
|
||||||
|
ariaLabel={t('albumDetail.ratingLabel')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||||
|
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
|
||||||
|
setStarredOverride(song.id, false);
|
||||||
|
return unstar(song.id, 'song');
|
||||||
|
})}>
|
||||||
|
<HeartCrack size={14} /> {t('contextMenu.unfavorite')}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{type === 'album' && (() => {
|
{type === 'album' && (() => {
|
||||||
const album = item as SubsonicAlbum;
|
const album = item as SubsonicAlbum;
|
||||||
const albumRatingDisabled = entityRatingSupport === 'track_only';
|
const albumRatingDisabled = entityRatingSupport === 'track_only';
|
||||||
|
|||||||
@@ -211,6 +211,11 @@ export const deTranslation = {
|
|||||||
playAll: 'Alle abspielen',
|
playAll: 'Alle abspielen',
|
||||||
removeSong: 'Aus Favoriten entfernen',
|
removeSong: 'Aus Favoriten entfernen',
|
||||||
stations: 'Radiosender',
|
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: {
|
randomLanding: {
|
||||||
title: 'Mix erstellen',
|
title: 'Mix erstellen',
|
||||||
@@ -399,6 +404,10 @@ export const deTranslation = {
|
|||||||
durationHoursMinutes: '{{hours}} Std. {{minutes}} Min.',
|
durationHoursMinutes: '{{hours}} Std. {{minutes}} Min.',
|
||||||
durationMinutesOnly: '{{minutes}} Min.',
|
durationMinutesOnly: '{{minutes}} Min.',
|
||||||
updaterOpenGitHub: 'Auf GitHub öffnen',
|
updaterOpenGitHub: 'Auf GitHub öffnen',
|
||||||
|
filters: 'Filter',
|
||||||
|
more: 'mehr',
|
||||||
|
yearRange: 'Jahresbereich',
|
||||||
|
clearAll: 'Alles zurücksetzen',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Einstellungen',
|
title: 'Einstellungen',
|
||||||
|
|||||||
@@ -212,6 +212,11 @@ export const enTranslation = {
|
|||||||
playAll: 'Play all',
|
playAll: 'Play all',
|
||||||
removeSong: 'Remove from favorites',
|
removeSong: 'Remove from favorites',
|
||||||
stations: 'Radio Stations',
|
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: {
|
randomLanding: {
|
||||||
title: 'Build a Mix',
|
title: 'Build a Mix',
|
||||||
@@ -400,6 +405,10 @@ export const enTranslation = {
|
|||||||
durationHoursMinutes: '{{hours}}h {{minutes}}m',
|
durationHoursMinutes: '{{hours}}h {{minutes}}m',
|
||||||
durationMinutesOnly: '{{minutes}}m',
|
durationMinutesOnly: '{{minutes}}m',
|
||||||
updaterOpenGitHub: 'Open on GitHub',
|
updaterOpenGitHub: 'Open on GitHub',
|
||||||
|
filters: 'Filters',
|
||||||
|
more: 'more',
|
||||||
|
yearRange: 'Year Range',
|
||||||
|
clearAll: 'Clear all',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
|
|||||||
@@ -212,6 +212,11 @@ export const esTranslation = {
|
|||||||
playAll: 'Reproducir todo',
|
playAll: 'Reproducir todo',
|
||||||
removeSong: 'Quitar de favoritos',
|
removeSong: 'Quitar de favoritos',
|
||||||
stations: 'Estaciones de Radio',
|
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: {
|
randomLanding: {
|
||||||
title: 'Crear Mezcla',
|
title: 'Crear Mezcla',
|
||||||
@@ -401,6 +406,10 @@ export const esTranslation = {
|
|||||||
durationHoursMinutes: '{{hours}}h {{minutes}}m',
|
durationHoursMinutes: '{{hours}}h {{minutes}}m',
|
||||||
durationMinutesOnly: '{{minutes}}m',
|
durationMinutesOnly: '{{minutes}}m',
|
||||||
updaterOpenGitHub: 'Abrir en GitHub',
|
updaterOpenGitHub: 'Abrir en GitHub',
|
||||||
|
filters: 'Filtros',
|
||||||
|
more: 'más',
|
||||||
|
yearRange: 'Rango de años',
|
||||||
|
clearAll: 'Limpiar todo',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Configuración',
|
title: 'Configuración',
|
||||||
|
|||||||
@@ -211,6 +211,11 @@ export const frTranslation = {
|
|||||||
playAll: 'Tout lire',
|
playAll: 'Tout lire',
|
||||||
removeSong: 'Retirer des favoris',
|
removeSong: 'Retirer des favoris',
|
||||||
stations: 'Stations de radio',
|
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: {
|
randomLanding: {
|
||||||
title: 'Créer un mix',
|
title: 'Créer un mix',
|
||||||
@@ -399,6 +404,10 @@ export const frTranslation = {
|
|||||||
durationHoursMinutes: '{{hours}} h {{minutes}} min',
|
durationHoursMinutes: '{{hours}} h {{minutes}} min',
|
||||||
durationMinutesOnly: '{{minutes}} min',
|
durationMinutesOnly: '{{minutes}} min',
|
||||||
updaterOpenGitHub: 'Ouvrir sur GitHub',
|
updaterOpenGitHub: 'Ouvrir sur GitHub',
|
||||||
|
filters: 'Filtres',
|
||||||
|
more: 'plus',
|
||||||
|
yearRange: 'Plage d\'années',
|
||||||
|
clearAll: 'Tout effacer',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Paramètres',
|
title: 'Paramètres',
|
||||||
|
|||||||
@@ -211,6 +211,11 @@ export const nbTranslation = {
|
|||||||
playAll: 'Spill alle',
|
playAll: 'Spill alle',
|
||||||
removeSong: 'Fjern fra favoritter',
|
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: {
|
randomLanding: {
|
||||||
title: 'Lag en miks',
|
title: 'Lag en miks',
|
||||||
@@ -399,6 +404,10 @@ export const nbTranslation = {
|
|||||||
durationHoursMinutes: '{{hours}} t {{minutes}} min',
|
durationHoursMinutes: '{{hours}} t {{minutes}} min',
|
||||||
durationMinutesOnly: '{{minutes}} min',
|
durationMinutesOnly: '{{minutes}} min',
|
||||||
updaterOpenGitHub: 'Åpne på GitHub',
|
updaterOpenGitHub: 'Åpne på GitHub',
|
||||||
|
filters: 'Filter',
|
||||||
|
more: 'mer',
|
||||||
|
yearRange: 'Årsspenn',
|
||||||
|
clearAll: 'Tøm alt',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Innstillinger',
|
title: 'Innstillinger',
|
||||||
|
|||||||
@@ -210,6 +210,11 @@ export const nlTranslation = {
|
|||||||
playAll: 'Alles afspelen',
|
playAll: 'Alles afspelen',
|
||||||
removeSong: 'Verwijderen uit favorieten',
|
removeSong: 'Verwijderen uit favorieten',
|
||||||
stations: 'Radiostations',
|
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: {
|
randomLanding: {
|
||||||
title: 'Mix samenstellen',
|
title: 'Mix samenstellen',
|
||||||
@@ -398,6 +403,10 @@ export const nlTranslation = {
|
|||||||
durationHoursMinutes: '{{hours}} u {{minutes}} min',
|
durationHoursMinutes: '{{hours}} u {{minutes}} min',
|
||||||
durationMinutesOnly: '{{minutes}} min',
|
durationMinutesOnly: '{{minutes}} min',
|
||||||
updaterOpenGitHub: 'Openen op GitHub',
|
updaterOpenGitHub: 'Openen op GitHub',
|
||||||
|
filters: 'Filters',
|
||||||
|
more: 'meer',
|
||||||
|
yearRange: 'Jaarbereik',
|
||||||
|
clearAll: 'Alles wissen',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Instellingen',
|
title: 'Instellingen',
|
||||||
|
|||||||
@@ -215,6 +215,11 @@ export const ruTranslation = {
|
|||||||
playAll: 'Воспроизвести всё',
|
playAll: 'Воспроизвести всё',
|
||||||
removeSong: 'Убрать из избранного',
|
removeSong: 'Убрать из избранного',
|
||||||
stations: 'Радиостанции',
|
stations: 'Радиостанции',
|
||||||
|
showingFiltered: 'Показано {{filtered}} из {{total}} ({{artist}})',
|
||||||
|
showingCount: 'Показано {{filtered}} из {{total}}',
|
||||||
|
clearArtistFilter: 'Сбросить фильтр исполнителя',
|
||||||
|
noFilterResults: 'Нет результатов с выбранными фильтрами.',
|
||||||
|
allArtists: 'Все исполнители',
|
||||||
},
|
},
|
||||||
randomLanding: {
|
randomLanding: {
|
||||||
title: 'Собрать микс',
|
title: 'Собрать микс',
|
||||||
@@ -413,6 +418,10 @@ export const ruTranslation = {
|
|||||||
durationHoursMinutes: '{{hours}}ч {{minutes}}мин',
|
durationHoursMinutes: '{{hours}}ч {{minutes}}мин',
|
||||||
durationMinutesOnly: '{{minutes}}мин',
|
durationMinutesOnly: '{{minutes}}мин',
|
||||||
updaterOpenGitHub: 'Открыть на GitHub',
|
updaterOpenGitHub: 'Открыть на GitHub',
|
||||||
|
filters: 'Фильтры',
|
||||||
|
more: 'еще',
|
||||||
|
yearRange: 'Диапазон лет',
|
||||||
|
clearAll: 'Очистить всё',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Настройки',
|
title: 'Настройки',
|
||||||
|
|||||||
@@ -210,6 +210,11 @@ export const zhTranslation = {
|
|||||||
playAll: '全部播放',
|
playAll: '全部播放',
|
||||||
removeSong: '从收藏中移除',
|
removeSong: '从收藏中移除',
|
||||||
stations: '广播电台',
|
stations: '广播电台',
|
||||||
|
showingFiltered: '显示 {{filtered}} / {{total}} ({{artist}})',
|
||||||
|
showingCount: '显示 {{filtered}} / {{total}}',
|
||||||
|
clearArtistFilter: '清除艺术家筛选',
|
||||||
|
noFilterResults: '所选筛选条件下无结果。',
|
||||||
|
allArtists: '全部艺术家',
|
||||||
},
|
},
|
||||||
randomLanding: {
|
randomLanding: {
|
||||||
title: '创建混音',
|
title: '创建混音',
|
||||||
@@ -394,6 +399,10 @@ export const zhTranslation = {
|
|||||||
durationHoursMinutes: '{{hours}}小时{{minutes}}分钟',
|
durationHoursMinutes: '{{hours}}小时{{minutes}}分钟',
|
||||||
durationMinutesOnly: '{{minutes}}分钟',
|
durationMinutesOnly: '{{minutes}}分钟',
|
||||||
updaterOpenGitHub: '在 GitHub 上打开',
|
updaterOpenGitHub: '在 GitHub 上打开',
|
||||||
|
filters: '筛选器',
|
||||||
|
more: '更多',
|
||||||
|
yearRange: '年份范围',
|
||||||
|
clearAll: '清除全部',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: '设置',
|
title: '设置',
|
||||||
|
|||||||
+281
-9
@@ -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 { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||||
import AlbumRow from '../components/AlbumRow';
|
import AlbumRow from '../components/AlbumRow';
|
||||||
import ArtistRow from '../components/ArtistRow';
|
import ArtistRow from '../components/ArtistRow';
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from '../api/subsonic';
|
} from '../api/subsonic';
|
||||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||||
import StarRating from '../components/StarRating';
|
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 { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { unstar } from '../api/subsonic';
|
import { unstar } from '../api/subsonic';
|
||||||
@@ -18,16 +18,25 @@ import { useDragDrop } from '../contexts/DragDropContext';
|
|||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useSelectionStore } from '../store/selectionStore';
|
import { useSelectionStore } from '../store/selectionStore';
|
||||||
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
|
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
|
||||||
|
import GenreFilterBar from '../components/GenreFilterBar';
|
||||||
|
|
||||||
const FAV_COLUMNS: readonly ColDef[] = [
|
const FAV_COLUMNS: readonly ColDef[] = [
|
||||||
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
|
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
|
||||||
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
|
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
|
||||||
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
|
{ 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: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
|
||||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, 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 },
|
{ 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() {
|
export default function Favorites() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||||
@@ -36,6 +45,21 @@ export default function Favorites() {
|
|||||||
const [radioStations, setRadioStations] = useState<InternetRadioStation[]>([]);
|
const [radioStations, setRadioStations] = useState<InternetRadioStation[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// ── Sorting (3-state: asc → desc → reset) ────────────────────────────────
|
||||||
|
const [sortKey, setSortKey] = useState<string>('natural');
|
||||||
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||||
|
const [sortClickCount, setSortClickCount] = useState(0);
|
||||||
|
|
||||||
|
// ── Artist filtering ─────────────────────────────────────────────────────
|
||||||
|
const [selectedArtist, setSelectedArtist] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ── Genre filtering ──────────────────────────────────────────────────────
|
||||||
|
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// ── 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) ───────────────
|
// ── Column resize/visibility (must be before early return) ───────────────
|
||||||
const {
|
const {
|
||||||
colVisible, visibleCols, gridStyle,
|
colVisible, visibleCols, gridStyle,
|
||||||
@@ -75,6 +99,36 @@ export default function Favorites() {
|
|||||||
setSongs(prev => prev.filter(s => s.id !== id));
|
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' ? <ArrowUp size={12} style={{ marginLeft: 4, opacity: 0.7 }} /> : <ArrowDown size={12} style={{ marginLeft: 4, opacity: 0.7 }} />;
|
||||||
|
};
|
||||||
|
|
||||||
function unfavoriteStation(id: string) {
|
function unfavoriteStation(id: string) {
|
||||||
setRadioStations(prev => prev.filter(s => s.id !== id));
|
setRadioStations(prev => prev.filter(s => s.id !== id));
|
||||||
try {
|
try {
|
||||||
@@ -151,6 +205,68 @@ export default function Favorites() {
|
|||||||
loadAll();
|
loadAll();
|
||||||
}, [musicLibraryFilterVersion]);
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||||
@@ -158,9 +274,8 @@ export default function Favorites() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Check if user has any favorites (using original unfiltered lists)
|
||||||
const visibleSongs = songs.filter(s => starredOverrides[s.id] !== false);
|
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || songs.length > 0 || radioStations.length > 0;
|
||||||
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || visibleSongs.length > 0 || radioStations.length > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-body animate-fade-in" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
<div className="content-body animate-fade-in" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||||
@@ -194,13 +309,27 @@ export default function Favorites() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{visibleSongs.length > 0 && (
|
{(visibleSongs.length > 0 || selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && (
|
||||||
<section className="album-row-section">
|
<section className="album-row-section">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '0.75rem' }}>
|
{/* ── Section Header with Stats & Filters ───────────────────────── */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginBottom: '0.75rem' }}>
|
||||||
|
{/* Title Row with showing X of Y indicator */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
<h2 className="section-title" style={{ margin: 0 }}>{t('favorites.songs')}</h2>
|
<h2 className="section-title" style={{ margin: 0 }}>{t('favorites.songs')}</h2>
|
||||||
|
<span style={{ fontSize: '0.8rem', color: 'var(--muted)', fontStyle: 'italic' }}>
|
||||||
|
{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 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
|
disabled={visibleSongs.length === 0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (visibleSongs.length === 0) return;
|
||||||
const tracks = visibleSongs.map(songToTrack);
|
const tracks = visibleSongs.map(songToTrack);
|
||||||
playTrack(tracks[0], tracks);
|
playTrack(tracks[0], tracks);
|
||||||
}}
|
}}
|
||||||
@@ -210,7 +339,9 @@ export default function Favorites() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-surface"
|
className="btn btn-surface"
|
||||||
|
disabled={visibleSongs.length === 0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (visibleSongs.length === 0) return;
|
||||||
const tracks = visibleSongs.map(songToTrack);
|
const tracks = visibleSongs.map(songToTrack);
|
||||||
enqueue(tracks);
|
enqueue(tracks);
|
||||||
}}
|
}}
|
||||||
@@ -218,6 +349,90 @@ export default function Favorites() {
|
|||||||
<ListPlus size={15} />
|
<ListPlus size={15} />
|
||||||
{t('favorites.enqueueAll')}
|
{t('favorites.enqueueAll')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Filter Toggle Button */}
|
||||||
|
<button
|
||||||
|
className={`btn ${showFilters || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR ? 'btn-primary' : 'btn-surface'}`}
|
||||||
|
onClick={() => setShowFilters(v => !v)}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={14} />
|
||||||
|
{t('common.filters')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{(selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedArtist(null);
|
||||||
|
setSelectedGenres([]);
|
||||||
|
setYearRange([MIN_YEAR, CURRENT_YEAR]);
|
||||||
|
setSortKey('natural');
|
||||||
|
setSortClickCount(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
{t('common.clearAll')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters Panel */}
|
||||||
|
{showFilters && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', padding: '0.75rem', background: 'var(--surface)', borderRadius: '8px', marginTop: '0.25rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Year Range Filter */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.85rem', color: 'var(--muted)' }}>
|
||||||
|
<span>{t('common.yearRange')}:</span>
|
||||||
|
<span style={{ color: 'var(--accent)', fontWeight: 500 }}>{yearRange[0]} - {yearRange[1]}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={MIN_YEAR}
|
||||||
|
max={CURRENT_YEAR}
|
||||||
|
value={yearRange[0]}
|
||||||
|
onChange={e => {
|
||||||
|
const val = parseInt(e.target.value);
|
||||||
|
setYearRange(prev => [Math.min(val, prev[1] - 1), prev[1]]);
|
||||||
|
}}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={MIN_YEAR}
|
||||||
|
max={CURRENT_YEAR}
|
||||||
|
value={yearRange[1]}
|
||||||
|
onChange={e => {
|
||||||
|
const val = parseInt(e.target.value);
|
||||||
|
setYearRange(prev => [prev[0], Math.max(val, prev[0] + 1)]);
|
||||||
|
}}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedArtist && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedArtist(null)}
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.3rem',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={11} />
|
||||||
|
{t('favorites.clearArtistFilter')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="tracklist" style={{ padding: 0 }} ref={tracklistRef} onClick={e => {
|
<div className="tracklist" style={{ padding: 0 }} ref={tracklistRef} onClick={e => {
|
||||||
if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll();
|
if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll();
|
||||||
@@ -283,17 +498,34 @@ export default function Favorites() {
|
|||||||
}
|
}
|
||||||
if (key === 'title') {
|
if (key === 'title') {
|
||||||
const hasNextCol = colIndex + 1 < visibleCols.length;
|
const hasNextCol = colIndex + 1 < visibleCols.length;
|
||||||
|
const canSort = SORTABLE_COLUMNS.has('title');
|
||||||
return (
|
return (
|
||||||
<div key="title" style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
<div key="title" style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
paddingLeft: 12,
|
||||||
|
cursor: canSort ? 'pointer' : 'default',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
onClick={() => handleSortClick('title')}
|
||||||
|
>
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||||
|
{canSort && getSortIndicator('title')}
|
||||||
</div>
|
</div>
|
||||||
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
|
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (key === 'remove') return <div key="remove" />;
|
if (key === 'remove') return <div key="remove" />;
|
||||||
|
|
||||||
const isCentered = key === 'duration' || key === 'rating';
|
const isCentered = key === 'duration' || key === 'rating';
|
||||||
|
const canSort = SORTABLE_COLUMNS.has(key);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||||
<div
|
<div
|
||||||
@@ -304,9 +536,13 @@ export default function Favorites() {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: isCentered ? 'center' : 'flex-start',
|
justifyContent: isCentered ? 'center' : 'flex-start',
|
||||||
paddingLeft: isCentered ? 0 : 12,
|
paddingLeft: isCentered ? 0 : 12,
|
||||||
|
cursor: canSort ? 'pointer' : 'default',
|
||||||
|
userSelect: 'none',
|
||||||
}}
|
}}
|
||||||
|
onClick={() => canSort && handleSortClick(key)}
|
||||||
>
|
>
|
||||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
||||||
|
{canSort && getSortIndicator(key)}
|
||||||
</div>
|
</div>
|
||||||
{!isLastCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />}
|
{!isLastCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -352,7 +588,7 @@ export default function Favorites() {
|
|||||||
playTrack(track, visibleSongs.map(songToTrack));
|
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"
|
role="row"
|
||||||
onMouseDown={e => {
|
onMouseDown={e => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
@@ -392,6 +628,34 @@ export default function Favorites() {
|
|||||||
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}>{song.artist}</span>
|
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}>{song.artist}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
case 'album': return (
|
||||||
|
<div key="album" className="track-artist-cell">
|
||||||
|
{song.albumId ? (
|
||||||
|
<span
|
||||||
|
className="track-artist track-artist-link"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/album/${song.albumId}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{song.album}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="track-artist">{song.album}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'format': return (
|
||||||
|
<div key="format" className="track-meta">
|
||||||
|
{(song.suffix || song.bitRate) && (
|
||||||
|
<span className="track-codec">
|
||||||
|
{song.suffix?.toUpperCase()}
|
||||||
|
{song.suffix && song.bitRate && ' · '}
|
||||||
|
{song.bitRate && `${song.bitRate} kbps`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
case 'rating': return (
|
case 'rating': return (
|
||||||
<StarRating
|
<StarRating
|
||||||
key="rating"
|
key="rating"
|
||||||
@@ -417,7 +681,15 @@ export default function Favorites() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Empty state when filters return no results */}
|
||||||
|
{visibleSongs.length === 0 && (selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--muted)' }}>
|
||||||
|
{t('favorites.noFilterResults')}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -174,12 +174,12 @@ interface PlayerState {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
item: any;
|
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;
|
queueIndex?: number;
|
||||||
playlistId?: string;
|
playlistId?: string;
|
||||||
playlistSongIndex?: number;
|
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;
|
closeContextMenu: () => void;
|
||||||
|
|
||||||
songInfoModal: { isOpen: boolean; songId: string | null };
|
songInfoModal: { isOpen: boolean; songId: string | null };
|
||||||
|
|||||||
Reference in New Issue
Block a user