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.
|
* 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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
export const playlists = {
|
export const playlists = {
|
||||||
|
sortTooltip: '曲を並べ替え',
|
||||||
|
sortDateAddedNewest: '追加日(新しい順)',
|
||||||
|
sortDateAddedOldest: '追加日(古い順)',
|
||||||
title: 'プレイリスト',
|
title: 'プレイリスト',
|
||||||
newPlaylist: '新規プレイリスト',
|
newPlaylist: '新規プレイリスト',
|
||||||
unnamed: '名前のないプレイリスト',
|
unnamed: '名前のないプレイリスト',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
export const playlists = {
|
export const playlists = {
|
||||||
|
sortTooltip: 'Сортировать треки',
|
||||||
|
sortDateAddedNewest: 'Дата добавления (новые)',
|
||||||
|
sortDateAddedOldest: 'Дата добавления (старые)',
|
||||||
title: 'Плейлисты',
|
title: 'Плейлисты',
|
||||||
newPlaylist: 'Новый плейлист',
|
newPlaylist: 'Новый плейлист',
|
||||||
unnamed: 'Без названия',
|
unnamed: 'Без названия',
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
export const playlists = {
|
export const playlists = {
|
||||||
|
sortTooltip: '排序曲目',
|
||||||
|
sortDateAddedNewest: '添加日期(最新)',
|
||||||
|
sortDateAddedOldest: '添加日期(最早)',
|
||||||
title: '播放列表',
|
title: '播放列表',
|
||||||
newPlaylist: '新建播放列表',
|
newPlaylist: '新建播放列表',
|
||||||
unnamed: '未命名播放列表',
|
unnamed: '未命名播放列表',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user