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
@@ -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';
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 interface DisplayedSongsOptions {
@@ -18,6 +18,13 @@ export function getDisplayedSongs(songs: SubsonicSong[], opts: DisplayedSongsOpt
if (!q && opts.sortKey === 'natural') return songs;
let result = [...songs];
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') {
result.sort((a, b) => {
let av: string | number;