From 1a2352009b5c65459b57b7a9192dc3033a22bbb7 Mon Sep 17 00:00:00 2001 From: "Kveld." Date: Sun, 12 Apr 2026 06:25:40 -0300 Subject: [PATCH] feat(tracklist): column-header sorting for albums & playlists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces dedicated sort buttons with clickable column headers in album and playlist detail views. Three-click cycle: asc → desc → natural order. Sortable by title, artist, album, favorite, rating, duration. Active column shown bold with ▲/▼ indicator. --- src/components/AlbumTrackList.tsx | 65 ++++++++++++++++-- src/pages/AlbumDetail.tsx | 78 ++++++++++++++------- src/pages/PlaylistDetail.tsx | 110 +++++++++++++++++++++++------- 3 files changed, 197 insertions(+), 56 deletions(-) diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index 520969d6..a7fa9327 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -44,6 +44,8 @@ const CENTERED_COLS = new Set(['favorite', 'rating', 'duration']); // ── Props ───────────────────────────────────────────────────────────────────── +export type SortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration'; + interface AlbumTrackListProps { songs: SubsonicSong[]; sorted?: boolean; @@ -57,6 +59,9 @@ interface AlbumTrackListProps { onRate: (songId: string, rating: number) => void; onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void; onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => void; + sortKey?: SortKey; + sortDir?: 'asc' | 'desc'; + onSort?: (key: SortKey) => void; } // ── TrackRow (memoised) ─────────────────────────────────────────────────────── @@ -262,6 +267,9 @@ export default function AlbumTrackList({ onRate, onToggleSongStar, onContextMenu, + sortKey, + sortDir, + onSort, }: AlbumTrackListProps) { const { t } = useTranslation(); const isMobile = useIsMobile(); @@ -375,12 +383,33 @@ export default function AlbumTrackList({ const currentTrackId = currentTrack?.id ?? null; + // ── Sortable columns ────────────────────────────────────────────────────── + const SORTABLE_COLS = new Set(['title', 'artist', 'album', 'favorite', 'rating', 'duration']); + + const isSortable = (key: ColKey | string): key is SortKey => SORTABLE_COLS.has(key as ColKey); + + const handleHeaderClick = (key: ColKey | string) => { + if (!isSortable(key) || !onSort) return; + onSort(key); + }; + + const renderSortIndicator = (key: SortKey) => { + if (sortKey !== key) return null; + return ( + + {sortDir === 'asc' ? '▲' : '▼'} + + ); + }; + // ── Header cell renderer ────────────────────────────────────────────────── const renderHeaderCell = (colDef: ColDef, colIndex: number) => { const key = colDef.key as ColKey; const isLastCol = colIndex === visibleCols.length - 1; const isCentered = CENTERED_COLS.has(key); const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : ''; + const canSort = isSortable(key) && onSort; + const isActive = canSort && sortKey === key; if (key === 'num') { return ( @@ -398,9 +427,23 @@ export default function AlbumTrackList({ if (key === 'title') { const hasNextCol = colIndex + 1 < visibleCols.length; return ( -
+
handleHeaderClick(key)} + className={isActive ? 'tracklist-header-cell-active' : ''} + >
- {label} + {label} + {canSort && renderSortIndicator(key as SortKey)}
{hasNextCol && (
startResize(e, colIndex + 1, -1)} /> @@ -411,7 +454,20 @@ export default function AlbumTrackList({ const isResizable = !isLastCol; return ( -
+
handleHeaderClick(key)} + className={isActive ? 'tracklist-header-cell-active' : ''} + >
- {label} + {label} + {canSort && isSortable(key) && renderSortIndicator(key as SortKey)}
{isResizable && (
startResize(e, colIndex, 1)} /> diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index 6ac86239..fde999c2 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -58,8 +58,9 @@ export default function AlbumDetail() { const [albumEntityRating, setAlbumEntityRating] = useState(0); const [filterText, setFilterText] = useState(''); - const [sortKey, setSortKey] = useState<'natural' | 'title' | 'artist'>('natural'); + const [sortKey, setSortKey] = useState<'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration'>('natural'); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); + const [sortClickCount, setSortClickCount] = useState(0); // Derive a stable albumId for the selectors below (empty string when not yet loaded). const albumId = album?.album.id ?? ''; @@ -262,6 +263,31 @@ const handleEnqueueAll = () => { deleteAlbum(album.album.id, serverId); }; + const handleSort = (key: typeof sortKey) => { + if (key === 'natural') return; + if (sortKey === key) { + const nextCount = sortClickCount + 1; + if (nextCount >= 3) { + setSortKey('natural'); + setSortDir('asc'); + setSortClickCount(0); + } else { + setSortDir(d => d === 'asc' ? 'desc' : 'asc'); + setSortClickCount(nextCount); + } + } else { + setSortKey(key); + setSortDir('asc'); + setSortClickCount(1); + } + }; + + // Must be before early returns — hooks must be called unconditionally. + const mergedStarredSongs = useMemo(() => new Set([ + ...[...starredSongs].filter(id => starredOverrides[id] !== false), + ...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k), + ]), [starredSongs, starredOverrides]); + const displayedSongs = useMemo(() => { if (!album) return []; const q = filterText.trim().toLowerCase(); @@ -270,13 +296,31 @@ const handleEnqueueAll = () => { if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q)); if (sortKey !== 'natural') { result.sort((a, b) => { - const av = sortKey === 'title' ? a.title : (a.artist ?? ''); - const bv = sortKey === 'title' ? b.title : (b.artist ?? ''); - return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av); + let av: string | number; + let bv: string | number; + switch (sortKey) { + case 'title': av = a.title; bv = b.title; break; + case 'artist': av = a.artist ?? ''; bv = b.artist ?? ''; break; + case 'album': av = a.album ?? ''; bv = b.album ?? ''; break; + case 'favorite': + av = mergedStarredSongs.has(a.id) ? 1 : 0; + bv = mergedStarredSongs.has(b.id) ? 1 : 0; + break; + case 'rating': + av = ratings[a.id] ?? userRatingOverrides[a.id] ?? a.userRating ?? 0; + bv = ratings[b.id] ?? userRatingOverrides[b.id] ?? b.userRating ?? 0; + break; + case 'duration': av = a.duration ?? 0; bv = b.duration ?? 0; break; + default: av = a.title; bv = b.title; + } + if (typeof av === 'number' && typeof bv === 'number') { + return sortDir === 'asc' ? av - bv : bv - av; + } + return sortDir === 'asc' ? (av as string).localeCompare(bv as string) : (bv as string).localeCompare(av as string); }); } return result; - }, [album, filterText, sortKey, sortDir]); + }, [album, filterText, sortKey, sortDir, mergedStarredSongs, ratings, userRatingOverrides]); // Hooks must be called unconditionally — derive from nullable album state. // useMemo is required: buildCoverArtUrl generates a new salt on every call, so without @@ -286,12 +330,6 @@ const handleEnqueueAll = () => { const coverKey = useMemo(() => album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '', [album?.album.coverArt]); const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey); - // Must be before early returns — hooks must be called unconditionally. - const mergedStarredSongs = useMemo(() => new Set([ - ...[...starredSongs].filter(id => starredOverrides[id] !== false), - ...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k), - ]), [starredSongs, starredOverrides]); - if (loading) return
; if (!album) return
{t('albumDetail.notFound')}
; @@ -354,21 +392,6 @@ const handleEnqueueAll = () => { >× )}
-
- {(['natural', 'title', 'artist'] as const).map(key => ( - - ))} -
)} @@ -385,6 +408,9 @@ const handleEnqueueAll = () => { onRate={handleRate} onToggleSongStar={toggleSongStar} onContextMenu={openContextMenu} + sortKey={sortKey} + sortDir={sortDir} + onSort={handleSort} /> {relatedAlbums.length > 0 && ( diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 66ccd705..122c8a3e 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -114,8 +114,9 @@ export default function PlaylistDetail() { const [editingMeta, setEditingMeta] = useState(false); const [customCoverId, setCustomCoverId] = useState(null); const [filterText, setFilterText] = useState(''); - const [sortKey, setSortKey] = useState<'natural' | 'title' | 'artist'>('natural'); + const [sortKey, setSortKey] = useState<'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration'>('natural'); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); + const [sortClickCount, setSortClickCount] = useState(0); const [starredSongs, setStarredSongs] = useState>(new Set()); const [hoveredSuggestionId, setHoveredSuggestionId] = useState(null); const [contextMenuSongId, setContextMenuSongId] = useState(null); @@ -471,13 +472,27 @@ export default function PlaylistDetail() { if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q)); if (sortKey !== 'natural') { result.sort((a, b) => { - const av = sortKey === 'title' ? a.title : (a.artist ?? ''); - const bv = sortKey === 'title' ? b.title : (b.artist ?? ''); - return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av); + let av: string | number; + let bv: string | number; + const effectiveRating = (s: SubsonicSong) => ratings[s.id] ?? userRatingOverrides[s.id] ?? s.userRating ?? 0; + const effectiveStarred = (s: SubsonicSong) => (s.id in starredOverrides ? starredOverrides[s.id] : starredSongs.has(s.id)) ? 1 : 0; + switch (sortKey) { + case 'title': av = a.title; bv = b.title; break; + case 'artist': av = a.artist ?? ''; bv = b.artist ?? ''; break; + case 'album': av = a.album ?? ''; bv = b.album ?? ''; break; + case 'favorite': av = effectiveStarred(a); bv = effectiveStarred(b); break; + case 'rating': av = effectiveRating(a); bv = effectiveRating(b); break; + case 'duration': av = a.duration ?? 0; bv = b.duration ?? 0; break; + default: av = a.title; bv = b.title; + } + if (typeof av === 'number' && typeof bv === 'number') { + return sortDir === 'asc' ? av - bv : bv - av; + } + return sortDir === 'asc' ? (av as string).localeCompare(bv as string) : (bv as string).localeCompare(av as string); }); } return result; - }, [songs, filterText, sortKey, sortDir]); + }, [songs, filterText, sortKey, sortDir, ratings, userRatingOverrides, starredOverrides, starredSongs]); const displayedTracks = useMemo( () => displayedSongs === songs ? tracks : displayedSongs.map(songToTrack), [displayedSongs, songs, tracks], @@ -706,23 +721,6 @@ export default function PlaylistDetail() { >× )}
-
- {(['natural', 'title', 'artist'] as const).map(key => ( - - ))} -
)} @@ -777,6 +775,38 @@ export default function PlaylistDetail() { const isLastCol = colIndex === visibleCols.length - 1; const isCentered = PL_CENTERED.has(key); const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : ''; + const sortableCols = new Set(['title', 'artist', 'favorite', 'rating', 'duration', 'album']); + const canSort = sortableCols.has(key); + const isSortActive = canSort && sortKey === key; + + const handleSortClick = () => { + if (!canSort) return; + if (sortKey === key) { + const nextCount = sortClickCount + 1; + if (nextCount >= 3) { + setSortKey('natural'); + setSortDir('asc'); + setSortClickCount(0); + } else { + setSortDir(d => d === 'asc' ? 'desc' : 'asc'); + setSortClickCount(nextCount); + } + } else { + setSortKey(key as typeof sortKey); + setSortDir('asc'); + setSortClickCount(1); + } + }; + + const renderSortIndicator = () => { + if (!isSortActive) return null; + return ( + + {sortDir === 'asc' ? '▲' : '▼'} + + ); + }; + if (key === 'num') return (
+
- {label} + {label} + {canSort && renderSortIndicator()}
{hasNextCol &&
startResize(e, colIndex + 1, -1)} />}
@@ -800,7 +844,20 @@ export default function PlaylistDetail() { } if (key === 'delete') return
; return ( -
+
- {label} + {label} + {canSort && renderSortIndicator()}
{!isLastCol && key !== 'delete' && (
startResize(e, colIndex, 1)} />