mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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<V extends string> {
|
||||
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<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 [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 updatePopStyle = () => {
|
||||
const updatePopStyle = useCallback(() => {
|
||||
if (!triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
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 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<V extends string>({ 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<V extends string>({ value, options, onChang
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('scroll', onResize, true);
|
||||
};
|
||||
}, [open]);
|
||||
}, [open, updatePopStyle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
@@ -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<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 {
|
||||
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<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 (
|
||||
<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 }}>
|
||||
<Search size={16} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)', pointerEvents: 'none' }} />
|
||||
<input
|
||||
@@ -30,6 +114,16 @@ export default function PlaylistFilterToolbar({ filterText, setFilterText }: Pro
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<SortDropdown
|
||||
value={currentSortValue}
|
||||
options={sortOptions}
|
||||
onChange={onSortChange}
|
||||
tooltip={t('playlists.sortTooltip')}
|
||||
ariaLabel={t('playlists.sortTooltip')}
|
||||
align="right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export const playlists = {
|
||||
sortTooltip: '曲を並べ替え',
|
||||
sortDateAddedNewest: '追加日(新しい順)',
|
||||
sortDateAddedOldest: '追加日(古い順)',
|
||||
title: 'プレイリスト',
|
||||
newPlaylist: '新規プレイリスト',
|
||||
unnamed: '名前のないプレイリスト',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export const playlists = {
|
||||
sortTooltip: 'Сортировать треки',
|
||||
sortDateAddedNewest: 'Дата добавления (новые)',
|
||||
sortDateAddedOldest: 'Дата добавления (старые)',
|
||||
title: 'Плейлисты',
|
||||
newPlaylist: 'Новый плейлист',
|
||||
unnamed: 'Без названия',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export const playlists = {
|
||||
sortTooltip: '排序曲目',
|
||||
sortDateAddedNewest: '添加日期(最新)',
|
||||
sortDateAddedOldest: '添加日期(最早)',
|
||||
title: '播放列表',
|
||||
newPlaylist: '新建播放列表',
|
||||
unnamed: '未命名播放列表',
|
||||
|
||||
@@ -293,7 +293,15 @@ export default function PlaylistDetail() {
|
||||
|
||||
{/* ── Filter / sort toolbar ── */}
|
||||
{songs.length > 0 && (
|
||||
<PlaylistFilterToolbar filterText={filterText} setFilterText={setFilterText} />
|
||||
<PlaylistFilterToolbar
|
||||
filterText={filterText}
|
||||
setFilterText={setFilterText}
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
setSortKey={setSortKey}
|
||||
setSortDir={setSortDir}
|
||||
setSortClickCount={setSortClickCount}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 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 {
|
||||
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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user