feat(playlist): sort playlist tracks by date added (#1191)

* feat(sort-dropdown): support a right-aligned popover via align prop

* feat(playlist): sort playlist tracks by date added

* docs(changelog): playlist sort by date added (#1191)

* docs(credits): playlist sort by date added (#1191)
This commit is contained in:
Psychotoxical
2026-06-25 18:29:35 +02:00
committed by GitHub
parent d49424e95b
commit 00512df207
19 changed files with 249 additions and 11 deletions
+6
View File
@@ -121,6 +121,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* New **Compact buttons** setting under Settings → Appearance. Switch the action and toolbar buttons between large labelled buttons and small icon-only ones — across album, artist and playlist headers, the shared browse toolbars (sort, filters, multi-select), and the Most Played sort/filter controls. Defaults to large, so nothing changes unless you turn it on. On phones the album header keeps its large touch targets. * New **Compact buttons** setting under Settings → Appearance. Switch the action and toolbar buttons between large labelled buttons and small icon-only ones — across album, artist and playlist headers, the shared browse toolbars (sort, filters, multi-select), and the Most Played sort/filter controls. Defaults to large, so nothing changes unless you turn it on. On phones the album header keeps its large touch targets.
### Playlists — sort by date added
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1191](https://github.com/Psychotoxical/psysonic/pull/1191)**, suggested by SinFist
* Sort a playlist by **Date added** (newest or oldest first), or by title, artist, album and the other columns, from a new sort dropdown in the playlist filter toolbar. The Subsonic API has no per-track "added on" date, so this follows the playlist's own order — servers add new tracks at the end, so newest-first puts your latest additions on top.
## Changed ## Changed
+14 -7
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { ArrowDownUp, Check } from 'lucide-react'; import { ArrowDownUp, Check } from 'lucide-react';
import { tooltipAttrs } from './tooltipAttrs'; import { tooltipAttrs } from './tooltipAttrs';
@@ -15,9 +15,15 @@ interface Props<V extends string> {
ariaLabel?: string; ariaLabel?: string;
/** Hover tooltip describing the action (shown below the trigger). */ /** Hover tooltip describing the action (shown below the trigger). */
tooltip?: string; tooltip?: string;
/**
* Horizontal anchor of the popover. `right` opens it leftwards (right edge
* aligned to the trigger) — use it when the trigger is docked to the right,
* e.g. next to an open side panel, so the popover never opens off-screen.
*/
align?: 'left' | 'right';
} }
export default function SortDropdown<V extends string>({ value, options, onChange, ariaLabel, tooltip }: Props<V>) { export default function SortDropdown<V extends string>({ value, options, onChange, ariaLabel, tooltip, align = 'left' }: Props<V>) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [popStyle, setPopStyle] = useState<React.CSSProperties>({}); const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
@@ -26,7 +32,7 @@ export default function SortDropdown<V extends string>({ value, options, onChang
const current = options.find(o => o.value === value); const current = options.find(o => o.value === value);
const updatePopStyle = () => { const updatePopStyle = useCallback(() => {
if (!triggerRef.current) return; if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect(); const rect = triggerRef.current.getBoundingClientRect();
const MARGIN = 6; const MARGIN = 6;
@@ -35,8 +41,9 @@ export default function SortDropdown<V extends string>({ value, options, onChang
const spaceBelow = window.innerHeight - rect.bottom - MARGIN; const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
const spaceAbove = rect.top - MARGIN; const spaceAbove = rect.top - MARGIN;
const useAbove = spaceBelow < 160 && spaceAbove > spaceBelow; const useAbove = spaceBelow < 160 && spaceAbove > spaceBelow;
const anchorLeft = align === 'right' ? rect.right - WIDTH : rect.left;
const left = Math.min( const left = Math.min(
Math.max(rect.left, 8), Math.max(anchorLeft, 8),
window.innerWidth - WIDTH - 8, window.innerWidth - WIDTH - 8,
); );
setPopStyle({ setPopStyle({
@@ -49,12 +56,12 @@ export default function SortDropdown<V extends string>({ value, options, onChang
maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow), maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow),
zIndex: 99998, zIndex: 99998,
}); });
}; }, [align]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!open) return; if (!open) return;
updatePopStyle(); updatePopStyle();
}, [open]); }, [open, updatePopStyle]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -65,7 +72,7 @@ export default function SortDropdown<V extends string>({ value, options, onChang
window.removeEventListener('resize', onResize); window.removeEventListener('resize', onResize);
window.removeEventListener('scroll', onResize, true); window.removeEventListener('scroll', onResize, true);
}; };
}, [open]); }, [open, updatePopStyle]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -1,16 +1,100 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Search, X } from 'lucide-react'; import { Search, X } from 'lucide-react';
import SortDropdown, { type SortOption } from '../SortDropdown';
import type { PlaylistSortKey, PlaylistSortDir } from '../../utils/playlist/playlistDisplayedSongs';
type PlaylistSortValue =
| 'added-newest'
| 'added-oldest'
| 'title'
| 'artist'
| 'album'
| 'duration'
| 'favorite'
| 'rating'
| 'plays';
const SORT_MAP: Record<PlaylistSortValue, { key: PlaylistSortKey; dir: PlaylistSortDir }> = {
'added-newest': { key: 'position', dir: 'desc' },
'added-oldest': { key: 'position', dir: 'asc' },
title: { key: 'title', dir: 'asc' },
artist: { key: 'artist', dir: 'asc' },
album: { key: 'album', dir: 'asc' },
duration: { key: 'duration', dir: 'asc' },
favorite: { key: 'favorite', dir: 'desc' },
rating: { key: 'rating', dir: 'desc' },
plays: { key: 'playCount', dir: 'desc' },
};
// Column keys whose dropdown value is the same string as the sort key.
const DIRECT_COLUMN_SORTS = new Set<PlaylistSortKey>([
'title',
'artist',
'album',
'duration',
'favorite',
'rating',
]);
interface Props { interface Props {
filterText: string; filterText: string;
setFilterText: (v: string) => void; setFilterText: (v: string) => void;
sortKey: PlaylistSortKey;
sortDir: PlaylistSortDir;
setSortKey: (k: PlaylistSortKey) => void;
setSortDir: (d: PlaylistSortDir) => void;
setSortClickCount: (n: number) => void;
} }
export default function PlaylistFilterToolbar({ filterText, setFilterText }: Props) { export default function PlaylistFilterToolbar({
filterText,
setFilterText,
sortKey,
sortDir,
setSortKey,
setSortDir,
setSortClickCount,
}: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
// The dropdown and the column-header clicks drive the same (sortKey, sortDir)
// state. Map the current state back to a dropdown value so the two stay in
// sync; playlist load order (natural) shows as "date added (oldest)" since
// they are the same ordering.
const currentSortValue: PlaylistSortValue =
sortKey === 'position'
? sortDir === 'desc'
? 'added-newest'
: 'added-oldest'
: sortKey === 'playCount'
? 'plays'
: DIRECT_COLUMN_SORTS.has(sortKey)
? (sortKey as PlaylistSortValue)
: 'added-oldest';
const sortOptions: SortOption<PlaylistSortValue>[] = [
{ value: 'added-newest', label: t('playlists.sortDateAddedNewest') },
{ value: 'added-oldest', label: t('playlists.sortDateAddedOldest') },
{ value: 'title', label: t('albumDetail.trackTitle') },
{ value: 'artist', label: t('albumDetail.trackArtist') },
{ value: 'album', label: t('albumDetail.trackAlbum') },
{ value: 'duration', label: t('albumDetail.trackDuration') },
{ value: 'favorite', label: t('albumDetail.trackFavorite') },
{ value: 'rating', label: t('albumDetail.trackRating') },
{ value: 'plays', label: t('albumDetail.trackPlayCount') },
];
const onSortChange = (v: PlaylistSortValue) => {
const { key, dir } = SORT_MAP[v];
setSortKey(key);
setSortDir(dir);
// Reset the column click cycle so a follow-up header click starts cleanly.
setSortClickCount(1);
};
return ( return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '8px 16px', flexWrap: 'wrap' }}> <div className="playlist-filter-toolbar" style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '8px 16px', flexWrap: 'wrap' }}>
<div style={{ position: 'relative', flex: '1 1 160px', maxWidth: 260 }}> <div style={{ position: 'relative', flex: '1 1 160px', maxWidth: 260 }}>
<Search size={16} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)', pointerEvents: 'none' }} /> <Search size={16} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)', pointerEvents: 'none' }} />
<input <input
@@ -30,6 +114,16 @@ export default function PlaylistFilterToolbar({ filterText, setFilterText }: Pro
</button> </button>
)} )}
</div> </div>
<div style={{ marginLeft: 'auto' }}>
<SortDropdown
value={currentSortValue}
options={sortOptions}
onChange={onSortChange}
tooltip={t('playlists.sortTooltip')}
ariaLabel={t('playlists.sortTooltip')}
align="right"
/>
</div>
</div> </div>
); );
} }
+1
View File
@@ -388,6 +388,7 @@ const CONTRIBUTOR_ENTRIES = [
'Hungarian (Magyar) translation (PR #1149)', 'Hungarian (Magyar) translation (PR #1149)',
'Theme scheduler — follow the OS light/dark setting as an alternative to the time-based day/night schedule (PR #1163)', 'Theme scheduler — follow the OS light/dark setting as an alternative to the time-based day/night schedule (PR #1163)',
'Compact buttons — appearance toggle that switches action and toolbar buttons to icon-only across detail headers and browse views, app-wide (PR #1189)', 'Compact buttons — appearance toggle that switches action and toolbar buttons to icon-only across detail headers and browse views, app-wide (PR #1189)',
'Playlist sort — sort a playlist by date added (newest/oldest) or any track column from a visible sort dropdown (PR #1191)',
], ],
}, },
{ {
+3
View File
@@ -1,4 +1,7 @@
export const playlists = { export const playlists = {
sortTooltip: 'Titel sortieren',
sortDateAddedNewest: 'Hinzugefügt (neueste)',
sortDateAddedOldest: 'Hinzugefügt (älteste)',
title: 'Playlists', title: 'Playlists',
newPlaylist: 'Neue Playlist', newPlaylist: 'Neue Playlist',
unnamed: 'Unbenannte Playlist', unnamed: 'Unbenannte Playlist',
+3
View File
@@ -1,4 +1,7 @@
export const playlists = { export const playlists = {
sortTooltip: 'Sort tracks',
sortDateAddedNewest: 'Date added (newest)',
sortDateAddedOldest: 'Date added (oldest)',
title: 'Playlists', title: 'Playlists',
newPlaylist: 'New Playlist', newPlaylist: 'New Playlist',
unnamed: 'Unnamed Playlist', unnamed: 'Unnamed Playlist',
+3
View File
@@ -1,4 +1,7 @@
export const playlists = { export const playlists = {
sortTooltip: 'Ordenar canciones',
sortDateAddedNewest: 'Fecha de adición (recientes)',
sortDateAddedOldest: 'Fecha de adición (antiguos)',
title: 'Listas de Reproducción', title: 'Listas de Reproducción',
newPlaylist: 'Nueva Lista', newPlaylist: 'Nueva Lista',
unnamed: 'Lista sin nombre', unnamed: 'Lista sin nombre',
+3
View File
@@ -1,4 +1,7 @@
export const playlists = { export const playlists = {
sortTooltip: 'Trier les titres',
sortDateAddedNewest: "Date d'ajout (récents)",
sortDateAddedOldest: "Date d'ajout (anciens)",
title: 'Playlists', title: 'Playlists',
newPlaylist: 'Nouvelle playlist', newPlaylist: 'Nouvelle playlist',
unnamed: 'Playlist sans nom', unnamed: 'Playlist sans nom',
+3
View File
@@ -1,4 +1,7 @@
export const playlists = { export const playlists = {
sortTooltip: 'Számok rendezése',
sortDateAddedNewest: 'Hozzáadás dátuma (legújabb)',
sortDateAddedOldest: 'Hozzáadás dátuma (legrégebbi)',
title: 'Lejátszási listák', title: 'Lejátszási listák',
newPlaylist: 'Új lejátszási lista', newPlaylist: 'Új lejátszási lista',
unnamed: 'Névtelen lejátszási lista', unnamed: 'Névtelen lejátszási lista',
+3
View File
@@ -1,4 +1,7 @@
export const playlists = { export const playlists = {
sortTooltip: '曲を並べ替え',
sortDateAddedNewest: '追加日(新しい順)',
sortDateAddedOldest: '追加日(古い順)',
title: 'プレイリスト', title: 'プレイリスト',
newPlaylist: '新規プレイリスト', newPlaylist: '新規プレイリスト',
unnamed: '名前のないプレイリスト', unnamed: '名前のないプレイリスト',
+3
View File
@@ -1,4 +1,7 @@
export const playlists = { export const playlists = {
sortTooltip: 'Sorter spor',
sortDateAddedNewest: 'Dato lagt til (nyeste)',
sortDateAddedOldest: 'Dato lagt til (eldste)',
title: 'Spillelister', title: 'Spillelister',
newPlaylist: 'Ny spilleliste', newPlaylist: 'Ny spilleliste',
unnamed: 'Navnløs spilleliste', unnamed: 'Navnløs spilleliste',
+3
View File
@@ -1,4 +1,7 @@
export const playlists = { export const playlists = {
sortTooltip: 'Nummers sorteren',
sortDateAddedNewest: 'Datum toegevoegd (nieuwste)',
sortDateAddedOldest: 'Datum toegevoegd (oudste)',
title: 'Playlists', title: 'Playlists',
newPlaylist: 'Nieuwe playlist', newPlaylist: 'Nieuwe playlist',
unnamed: 'Naamloze playlist', unnamed: 'Naamloze playlist',
+3
View File
@@ -1,4 +1,7 @@
export const playlists = { export const playlists = {
sortTooltip: 'Sortează piesele',
sortDateAddedNewest: 'Data adăugării (cele mai noi)',
sortDateAddedOldest: 'Data adăugării (cele mai vechi)',
title: 'Playlisturi', title: 'Playlisturi',
newPlaylist: 'Playlist nou', newPlaylist: 'Playlist nou',
unnamed: 'Playlist fără nume', unnamed: 'Playlist fără nume',
+3
View File
@@ -1,4 +1,7 @@
export const playlists = { export const playlists = {
sortTooltip: 'Сортировать треки',
sortDateAddedNewest: 'Дата добавления (новые)',
sortDateAddedOldest: 'Дата добавления (старые)',
title: 'Плейлисты', title: 'Плейлисты',
newPlaylist: 'Новый плейлист', newPlaylist: 'Новый плейлист',
unnamed: 'Без названия', unnamed: 'Без названия',
+3
View File
@@ -1,4 +1,7 @@
export const playlists = { export const playlists = {
sortTooltip: '排序曲目',
sortDateAddedNewest: '添加日期(最新)',
sortDateAddedOldest: '添加日期(最早)',
title: '播放列表', title: '播放列表',
newPlaylist: '新建播放列表', newPlaylist: '新建播放列表',
unnamed: '未命名播放列表', unnamed: '未命名播放列表',
+9 -1
View File
@@ -293,7 +293,15 @@ export default function PlaylistDetail() {
{/* ── Filter / sort toolbar ── */} {/* ── Filter / sort toolbar ── */}
{songs.length > 0 && ( {songs.length > 0 && (
<PlaylistFilterToolbar filterText={filterText} setFilterText={setFilterText} /> <PlaylistFilterToolbar
filterText={filterText}
setFilterText={setFilterText}
sortKey={sortKey}
sortDir={sortDir}
setSortKey={setSortKey}
setSortDir={setSortDir}
setSortClickCount={setSortClickCount}
/>
)} )}
{/* ── Tracklist ── */} {/* ── Tracklist ── */}
@@ -57,3 +57,12 @@ html[data-button-size="small"] .mp-sort-btn .compact-btn-label,
html[data-button-size="small"] .mp-filter-btn .compact-btn-label { html[data-button-size="small"] .mp-filter-btn .compact-btn-label {
display: none; display: none;
} }
/* Playlist filter/sort toolbar: the SortDropdown collapses to icon-only in Small
too (label hidden, square button), reusing the shared `toolbar-btn-label`. */
html[data-button-size="small"] .playlist-filter-toolbar .toolbar-btn-label {
display: none;
}
html[data-button-size="small"] .playlist-filter-toolbar .btn-surface {
padding: 0.5rem;
}
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import type { SubsonicSong } from '../../api/subsonicTypes';
import {
getDisplayedSongs,
type DisplayedSongsOptions,
type PlaylistSortKey,
type PlaylistSortDir,
} from './playlistDisplayedSongs';
const song = (id: string, title = id, artist = ''): SubsonicSong =>
({ id, title, artist }) as SubsonicSong;
const opts = (over: Partial<DisplayedSongsOptions> = {}): DisplayedSongsOptions => ({
filterText: '',
sortKey: 'natural',
sortDir: 'asc',
ratings: {},
userRatingOverrides: {},
starredOverrides: {},
starredSongs: new Set<string>(),
...over,
});
const ids = (songs: SubsonicSong[]) => songs.map(s => s.id);
describe('getDisplayedSongs — position (date added)', () => {
// Playlist load order is oldest→newest (servers append new tracks at the end).
const list = [song('a'), song('b'), song('c'), song('d')];
it('ascending keeps the playlist load order (oldest → newest)', () => {
expect(ids(getDisplayedSongs(list, opts({ sortKey: 'position', sortDir: 'asc' })))).toEqual([
'a',
'b',
'c',
'd',
]);
});
it('descending reverses it (newest added first) — the requested behaviour', () => {
expect(ids(getDisplayedSongs(list, opts({ sortKey: 'position', sortDir: 'desc' })))).toEqual([
'd',
'c',
'b',
'a',
]);
});
it('never mutates the input array', () => {
const input = [song('a'), song('b'), song('c')];
getDisplayedSongs(input, opts({ sortKey: 'position', sortDir: 'desc' }));
expect(ids(input)).toEqual(['a', 'b', 'c']);
});
it('filters first, then reverses the surviving rows', () => {
const mixed = [song('1', 'alpha'), song('2', 'beta'), song('3', 'alphabet')];
const out = getDisplayedSongs(
mixed,
opts({ sortKey: 'position', sortDir: 'desc', filterText: 'alpha' }),
);
expect(ids(out)).toEqual(['3', '1']);
});
it("natural still ignores sortDir (it is the reset state, not a position sort)", () => {
const k: PlaylistSortKey = 'natural';
const d: PlaylistSortDir = 'desc';
expect(ids(getDisplayedSongs(list, opts({ sortKey: k, sortDir: d })))).toEqual([
'a',
'b',
'c',
'd',
]);
});
});
+8 -1
View File
@@ -1,6 +1,6 @@
import type { SubsonicSong } from '../../api/subsonicTypes'; import type { SubsonicSong } from '../../api/subsonicTypes';
export type PlaylistSortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration' | 'playCount' | 'lastPlayed' | 'bpm'; export type PlaylistSortKey = 'natural' | 'position' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration' | 'playCount' | 'lastPlayed' | 'bpm';
export type PlaylistSortDir = 'asc' | 'desc'; export type PlaylistSortDir = 'asc' | 'desc';
export interface DisplayedSongsOptions { export interface DisplayedSongsOptions {
@@ -18,6 +18,13 @@ export function getDisplayedSongs(songs: SubsonicSong[], opts: DisplayedSongsOpt
if (!q && opts.sortKey === 'natural') return songs; if (!q && opts.sortKey === 'natural') return songs;
let result = [...songs]; let result = [...songs];
if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q)); if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q));
if (opts.sortKey === 'position') {
// Playlist position is the "date added" proxy: servers append new tracks at
// the end, so ascending = oldest→newest (load order) and descending =
// newest→oldest. Reverse rather than compare — stable and O(n), and the
// Subsonic playlist response carries no per-entry timestamp to compare on.
return opts.sortDir === 'desc' ? result.reverse() : result;
}
if (opts.sortKey !== 'natural') { if (opts.sortKey !== 'natural') {
result.sort((a, b) => { result.sort((a, b) => {
let av: string | number; let av: string | number;