feat(tracklist): column-header sorting for albums & playlists

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.
This commit is contained in:
Kveld.
2026-04-12 06:25:40 -03:00
committed by GitHub
parent eedf6f9337
commit 1a2352009b
3 changed files with 197 additions and 56 deletions
+61 -4
View File
@@ -44,6 +44,8 @@ const CENTERED_COLS = new Set<ColKey>(['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<ColKey | 'album'>(['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 (
<span style={{ marginLeft: 4, fontSize: 10, opacity: 0.7 }}>
{sortDir === 'asc' ? '▲' : '▼'}
</span>
);
};
// ── 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 (
<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',
cursor: canSort ? 'pointer' : 'default',
userSelect: 'none',
}}
onClick={() => handleHeaderClick(key)}
className={isActive ? 'tracklist-header-cell-active' : ''}
>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isActive ? 600 : 400 }}>{label}</span>
{canSort && renderSortIndicator(key as SortKey)}
</div>
{hasNextCol && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
@@ -411,7 +454,20 @@ export default function AlbumTrackList({
const isResizable = !isLastCol;
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',
cursor: canSort ? 'pointer' : 'default',
userSelect: 'none',
}}
onClick={() => handleHeaderClick(key)}
className={isActive ? 'tracklist-header-cell-active' : ''}
>
<div
style={{
display: 'flex', width: '100%', height: '100%', alignItems: 'center',
@@ -419,7 +475,8 @@ export default function AlbumTrackList({
paddingLeft: isCentered ? 0 : 12,
}}
>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: isActive ? 600 : 400 }}>{label}</span>
{canSort && isSortable(key) && renderSortIndicator(key as SortKey)}
</div>
{isResizable && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
+52 -26
View File
@@ -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 <div className="loading-center"><div className="spinner" /></div>;
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
@@ -354,21 +392,6 @@ const handleEnqueueAll = () => {
>×</button>
)}
</div>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{(['natural', 'title', 'artist'] as const).map(key => (
<button
key={key}
className={`btn btn-sm ${sortKey === key ? 'btn-surface' : 'btn-ghost'}`}
onClick={() => {
if (sortKey === key && key !== 'natural') setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortKey(key); setSortDir('asc'); }
}}
>
{key === 'natural' ? t('albumDetail.sortNatural') : key === 'title' ? t('albumDetail.sortByTitle') : t('albumDetail.sortByArtist')}
{sortKey === key && key !== 'natural' && <span style={{ marginLeft: 3 }}>{sortDir === 'asc' ? '↑' : '↓'}</span>}
</button>
))}
</div>
</div>
)}
@@ -385,6 +408,9 @@ const handleEnqueueAll = () => {
onRate={handleRate}
onToggleSongStar={toggleSongStar}
onContextMenu={openContextMenu}
sortKey={sortKey}
sortDir={sortDir}
onSort={handleSort}
/>
{relatedAlbums.length > 0 && (
+84 -26
View File
@@ -114,8 +114,9 @@ export default function PlaylistDetail() {
const [editingMeta, setEditingMeta] = useState(false);
const [customCoverId, setCustomCoverId] = useState<string | null>(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<Set<string>>(new Set());
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(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() {
>×</button>
)}
</div>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{(['natural', 'title', 'artist'] as const).map(key => (
<button
key={key}
className={`btn btn-sm ${sortKey === key ? 'btn-surface' : 'btn-ghost'}`}
onClick={() => {
if (sortKey === key && key !== 'natural') setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortKey(key); setSortDir('asc'); }
}}
>
{key === 'natural' ? t('albumDetail.sortNatural')
: key === 'title' ? t('albumDetail.sortByTitle')
: t('albumDetail.sortByArtist')}
{sortKey === key && key !== 'natural' && <span style={{ marginLeft: 3 }}>{sortDir === 'asc' ? '↑' : '↓'}</span>}
</button>
))}
</div>
</div>
)}
@@ -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 (
<span style={{ marginLeft: 4, fontSize: 10, opacity: 0.7 }}>
{sortDir === 'asc' ? '▲' : '▼'}
</span>
);
};
if (key === 'num') return (
<div key="num" className="track-num">
<span
@@ -790,9 +820,23 @@ export default function PlaylistDetail() {
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
return (
<div key="title" style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div
key="title"
onClick={handleSortClick}
style={{
position: 'relative',
padding: 0,
margin: 0,
minWidth: 0,
overflow: 'hidden',
cursor: canSort ? 'pointer' : 'default',
userSelect: 'none',
}}
className={isSortActive ? 'tracklist-header-cell-active' : ''}
>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isSortActive ? 600 : 400 }}>{label}</span>
{canSort && renderSortIndicator()}
</div>
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
</div>
@@ -800,7 +844,20 @@ export default function PlaylistDetail() {
}
if (key === 'delete') return <div key="delete" />;
return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div
key={key}
onClick={handleSortClick}
style={{
position: 'relative',
padding: 0,
margin: 0,
minWidth: 0,
overflow: 'hidden',
cursor: canSort ? 'pointer' : 'default',
userSelect: 'none',
}}
className={isSortActive ? 'tracklist-header-cell-active' : ''}
>
<div
style={{
display: 'flex',
@@ -811,7 +868,8 @@ export default function PlaylistDetail() {
paddingLeft: isCentered ? 0 : 12,
}}
>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: isSortActive ? 600 : 400 }}>{label}</span>
{canSort && renderSortIndicator()}
</div>
{!isLastCol && key !== 'delete' && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />