From 00512df2075fcc9d6a43c46307dbc212a18cc755 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:29:35 +0200 Subject: [PATCH] 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) --- CHANGELOG.md | 6 ++ src/components/SortDropdown.tsx | 21 ++-- .../playlist/PlaylistFilterToolbar.tsx | 98 ++++++++++++++++++- src/config/settingsCredits.ts | 1 + src/locales/de/playlists.ts | 3 + src/locales/en/playlists.ts | 3 + src/locales/es/playlists.ts | 3 + src/locales/fr/playlists.ts | 3 + src/locales/hu/playlists.ts | 3 + src/locales/ja/playlists.ts | 3 + src/locales/nb/playlists.ts | 3 + src/locales/nl/playlists.ts | 3 + src/locales/ro/playlists.ts | 3 + src/locales/ru/playlists.ts | 3 + src/locales/zh/playlists.ts | 3 + src/pages/PlaylistDetail.tsx | 10 +- src/styles/components/compact-buttons.css | 9 ++ .../playlist/playlistDisplayedSongs.test.ts | 73 ++++++++++++++ src/utils/playlist/playlistDisplayedSongs.ts | 9 +- 19 files changed, 249 insertions(+), 11 deletions(-) create mode 100644 src/utils/playlist/playlistDisplayedSongs.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c80b89b8..ffc8e464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +### 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 diff --git a/src/components/SortDropdown.tsx b/src/components/SortDropdown.tsx index d4a118c8..41c97231 100644 --- a/src/components/SortDropdown.tsx +++ b/src/components/SortDropdown.tsx @@ -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 { ArrowDownUp, Check } from 'lucide-react'; import { tooltipAttrs } from './tooltipAttrs'; @@ -15,9 +15,15 @@ interface Props { ariaLabel?: string; /** Hover tooltip describing the action (shown below the trigger). */ 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({ value, options, onChange, ariaLabel, tooltip }: Props) { +export default function SortDropdown({ value, options, onChange, ariaLabel, tooltip, align = 'left' }: Props) { const [open, setOpen] = useState(false); const [popStyle, setPopStyle] = useState({}); @@ -26,7 +32,7 @@ export default function SortDropdown({ value, options, onChang const current = options.find(o => o.value === value); - const updatePopStyle = () => { + const updatePopStyle = useCallback(() => { if (!triggerRef.current) return; const rect = triggerRef.current.getBoundingClientRect(); const MARGIN = 6; @@ -35,8 +41,9 @@ export default function SortDropdown({ value, options, onChang const spaceBelow = window.innerHeight - rect.bottom - MARGIN; const spaceAbove = rect.top - MARGIN; const useAbove = spaceBelow < 160 && spaceAbove > spaceBelow; + const anchorLeft = align === 'right' ? rect.right - WIDTH : rect.left; const left = Math.min( - Math.max(rect.left, 8), + Math.max(anchorLeft, 8), window.innerWidth - WIDTH - 8, ); setPopStyle({ @@ -49,12 +56,12 @@ export default function SortDropdown({ value, options, onChang maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow), zIndex: 99998, }); - }; + }, [align]); useLayoutEffect(() => { if (!open) return; updatePopStyle(); - }, [open]); + }, [open, updatePopStyle]); useEffect(() => { if (!open) return; @@ -65,7 +72,7 @@ export default function SortDropdown({ value, options, onChang window.removeEventListener('resize', onResize); window.removeEventListener('scroll', onResize, true); }; - }, [open]); + }, [open, updatePopStyle]); useEffect(() => { if (!open) return; diff --git a/src/components/playlist/PlaylistFilterToolbar.tsx b/src/components/playlist/PlaylistFilterToolbar.tsx index 28851bbb..aaa399b2 100644 --- a/src/components/playlist/PlaylistFilterToolbar.tsx +++ b/src/components/playlist/PlaylistFilterToolbar.tsx @@ -1,16 +1,100 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; 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 = { + '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([ + 'title', + 'artist', + 'album', + 'duration', + 'favorite', + 'rating', +]); interface Props { filterText: string; 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(); + + // 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[] = [ + { 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 ( -
+
)}
+
+ +
); } diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index a15c515c..02ce0afe 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -388,6 +388,7 @@ const CONTRIBUTOR_ENTRIES = [ '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)', '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)', ], }, { diff --git a/src/locales/de/playlists.ts b/src/locales/de/playlists.ts index 8127dd90..98b50b99 100644 --- a/src/locales/de/playlists.ts +++ b/src/locales/de/playlists.ts @@ -1,4 +1,7 @@ export const playlists = { + sortTooltip: 'Titel sortieren', + sortDateAddedNewest: 'Hinzugefügt (neueste)', + sortDateAddedOldest: 'Hinzugefügt (älteste)', title: 'Playlists', newPlaylist: 'Neue Playlist', unnamed: 'Unbenannte Playlist', diff --git a/src/locales/en/playlists.ts b/src/locales/en/playlists.ts index 6120e738..81712ed5 100644 --- a/src/locales/en/playlists.ts +++ b/src/locales/en/playlists.ts @@ -1,4 +1,7 @@ export const playlists = { + sortTooltip: 'Sort tracks', + sortDateAddedNewest: 'Date added (newest)', + sortDateAddedOldest: 'Date added (oldest)', title: 'Playlists', newPlaylist: 'New Playlist', unnamed: 'Unnamed Playlist', diff --git a/src/locales/es/playlists.ts b/src/locales/es/playlists.ts index c8fd6961..f70d0a59 100644 --- a/src/locales/es/playlists.ts +++ b/src/locales/es/playlists.ts @@ -1,4 +1,7 @@ export const playlists = { + sortTooltip: 'Ordenar canciones', + sortDateAddedNewest: 'Fecha de adición (recientes)', + sortDateAddedOldest: 'Fecha de adición (antiguos)', title: 'Listas de Reproducción', newPlaylist: 'Nueva Lista', unnamed: 'Lista sin nombre', diff --git a/src/locales/fr/playlists.ts b/src/locales/fr/playlists.ts index d5b83669..b23288e6 100644 --- a/src/locales/fr/playlists.ts +++ b/src/locales/fr/playlists.ts @@ -1,4 +1,7 @@ export const playlists = { + sortTooltip: 'Trier les titres', + sortDateAddedNewest: "Date d'ajout (récents)", + sortDateAddedOldest: "Date d'ajout (anciens)", title: 'Playlists', newPlaylist: 'Nouvelle playlist', unnamed: 'Playlist sans nom', diff --git a/src/locales/hu/playlists.ts b/src/locales/hu/playlists.ts index ef22e636..03c8423b 100644 --- a/src/locales/hu/playlists.ts +++ b/src/locales/hu/playlists.ts @@ -1,4 +1,7 @@ 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', newPlaylist: 'Új lejátszási lista', unnamed: 'Névtelen lejátszási lista', diff --git a/src/locales/ja/playlists.ts b/src/locales/ja/playlists.ts index 9c12ce2a..e002ca90 100644 --- a/src/locales/ja/playlists.ts +++ b/src/locales/ja/playlists.ts @@ -1,4 +1,7 @@ export const playlists = { + sortTooltip: '曲を並べ替え', + sortDateAddedNewest: '追加日(新しい順)', + sortDateAddedOldest: '追加日(古い順)', title: 'プレイリスト', newPlaylist: '新規プレイリスト', unnamed: '名前のないプレイリスト', diff --git a/src/locales/nb/playlists.ts b/src/locales/nb/playlists.ts index ed62e2b6..fd911909 100644 --- a/src/locales/nb/playlists.ts +++ b/src/locales/nb/playlists.ts @@ -1,4 +1,7 @@ export const playlists = { + sortTooltip: 'Sorter spor', + sortDateAddedNewest: 'Dato lagt til (nyeste)', + sortDateAddedOldest: 'Dato lagt til (eldste)', title: 'Spillelister', newPlaylist: 'Ny spilleliste', unnamed: 'Navnløs spilleliste', diff --git a/src/locales/nl/playlists.ts b/src/locales/nl/playlists.ts index 640b494c..7ac9b5cb 100644 --- a/src/locales/nl/playlists.ts +++ b/src/locales/nl/playlists.ts @@ -1,4 +1,7 @@ export const playlists = { + sortTooltip: 'Nummers sorteren', + sortDateAddedNewest: 'Datum toegevoegd (nieuwste)', + sortDateAddedOldest: 'Datum toegevoegd (oudste)', title: 'Playlists', newPlaylist: 'Nieuwe playlist', unnamed: 'Naamloze playlist', diff --git a/src/locales/ro/playlists.ts b/src/locales/ro/playlists.ts index ce568f72..ca5084e6 100644 --- a/src/locales/ro/playlists.ts +++ b/src/locales/ro/playlists.ts @@ -1,4 +1,7 @@ export const playlists = { + sortTooltip: 'Sortează piesele', + sortDateAddedNewest: 'Data adăugării (cele mai noi)', + sortDateAddedOldest: 'Data adăugării (cele mai vechi)', title: 'Playlisturi', newPlaylist: 'Playlist nou', unnamed: 'Playlist fără nume', diff --git a/src/locales/ru/playlists.ts b/src/locales/ru/playlists.ts index 2a4b68ba..19629a70 100644 --- a/src/locales/ru/playlists.ts +++ b/src/locales/ru/playlists.ts @@ -1,4 +1,7 @@ export const playlists = { + sortTooltip: 'Сортировать треки', + sortDateAddedNewest: 'Дата добавления (новые)', + sortDateAddedOldest: 'Дата добавления (старые)', title: 'Плейлисты', newPlaylist: 'Новый плейлист', unnamed: 'Без названия', diff --git a/src/locales/zh/playlists.ts b/src/locales/zh/playlists.ts index 6b1c8a38..2814ea37 100644 --- a/src/locales/zh/playlists.ts +++ b/src/locales/zh/playlists.ts @@ -1,4 +1,7 @@ export const playlists = { + sortTooltip: '排序曲目', + sortDateAddedNewest: '添加日期(最新)', + sortDateAddedOldest: '添加日期(最早)', title: '播放列表', newPlaylist: '新建播放列表', unnamed: '未命名播放列表', diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index c976b69d..04df8f18 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -293,7 +293,15 @@ export default function PlaylistDetail() { {/* ── Filter / sort toolbar ── */} {songs.length > 0 && ( - + )} {/* ── Tracklist ── */} diff --git a/src/styles/components/compact-buttons.css b/src/styles/components/compact-buttons.css index 5fd8f981..6df35d03 100644 --- a/src/styles/components/compact-buttons.css +++ b/src/styles/components/compact-buttons.css @@ -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 { 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; +} diff --git a/src/utils/playlist/playlistDisplayedSongs.test.ts b/src/utils/playlist/playlistDisplayedSongs.test.ts new file mode 100644 index 00000000..16faeb46 --- /dev/null +++ b/src/utils/playlist/playlistDisplayedSongs.test.ts @@ -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 => ({ + filterText: '', + sortKey: 'natural', + sortDir: 'asc', + ratings: {}, + userRatingOverrides: {}, + starredOverrides: {}, + starredSongs: new Set(), + ...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', + ]); + }); +}); diff --git a/src/utils/playlist/playlistDisplayedSongs.ts b/src/utils/playlist/playlistDisplayedSongs.ts index e8e6d7ee..57af9a7c 100644 --- a/src/utils/playlist/playlistDisplayedSongs.ts +++ b/src/utils/playlist/playlistDisplayedSongs.ts @@ -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;