mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
efd85ffde3
* feat(tracklist): play count, last played, and BPM columns (#516) Adds three opt-in columns to Album / Playlist / Favorites tracklists, plus the same fields in the Song Info modal. Picks up Navidrome's existing `playCount` / `played` / `bpm` from the Subsonic response — no new API calls. - `SubsonicSong` gains `playCount`, `played`, `bpm` (already populated by Navidrome's Subsonic API, just unmapped in the TS model). - `albumTrackListHelpers.COLUMNS`, `PL_COLUMNS` (PlaylistDetail), and `FAV_COLUMNS` (Favorites) get the three new entries. Genre also added to the playlist column set for parity with the other two lists. - Render uses `.track-duration` (12px tabular, centered, muted) for the numeric stats and `.track-genre` (11px text, muted) for the relative last-played timestamp via the existing `formatLastSeen` helper. - Sort logic extended in `playlistDisplayedSongs`, `useAlbumDetailSort`, and `useFavoritesSongFiltering` for the three new keys. - `SongInfoModal` gets matching rows (BPM / Play count / Last played). - `useTracklistColumns.gridTemplate` / `gridMinWidth` fall back to the ColDef's `defaultWidth` when a visible column has no saved width (newly added column on an old prefs blob would otherwise emit `undefinedpx` and collapse the row layout until reset-to-defaults). - BPM cells skip the rendering when Navidrome returns 0 (default for untagged files) — show `—` instead of `0`. - i18n: `albumDetail.trackPlayCount / trackLastPlayed / trackBpm` and `songInfo.playCount / lastPlayed / bpm` in all 9 locales. Suggested by jbigginswyl (#516). * docs(changelog): tracklist Plays / Last played / BPM columns (#730)
99 lines
3.6 KiB
TypeScript
99 lines
3.6 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import type { SubsonicSong } from '../api/subsonicTypes';
|
|
|
|
export type AlbumSortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration' | 'playCount' | 'lastPlayed' | 'bpm';
|
|
|
|
interface UseAlbumDetailSortArgs {
|
|
songs: SubsonicSong[] | undefined;
|
|
filterText: string;
|
|
starredSongs: Set<string>;
|
|
ratings: Record<string, number>;
|
|
userRatingOverrides: Record<string, number>;
|
|
}
|
|
|
|
interface UseAlbumDetailSortResult {
|
|
sortKey: AlbumSortKey;
|
|
sortDir: 'asc' | 'desc';
|
|
handleSort: (key: AlbumSortKey) => void;
|
|
displayedSongs: SubsonicSong[];
|
|
}
|
|
|
|
/**
|
|
* Sort + text-filter pipeline for the album track list. Click cycle on a
|
|
* header column: asc → desc → off (back to `natural` order). Other
|
|
* columns reset to asc on first click.
|
|
*
|
|
* Rating reads use the same priority as the row renderer
|
|
* (`ratings[id] ?? userRatingOverrides[id] ?? song.userRating`) so the
|
|
* sort matches the visible stars during optimistic updates.
|
|
*/
|
|
export function useAlbumDetailSort({
|
|
songs,
|
|
filterText,
|
|
starredSongs,
|
|
ratings,
|
|
userRatingOverrides,
|
|
}: UseAlbumDetailSortArgs): UseAlbumDetailSortResult {
|
|
const [sortKey, setSortKey] = useState<AlbumSortKey>('natural');
|
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
|
const [sortClickCount, setSortClickCount] = useState(0);
|
|
|
|
const handleSort = (key: AlbumSortKey) => {
|
|
if (key === 'natural') return;
|
|
if (sortKey === key) {
|
|
const nextCount = sortClickCount + 1;
|
|
if (nextCount >= 3) {
|
|
setSortKey('natural');
|
|
setSortDir('asc');
|
|
setSortClickCount(0);
|
|
} else {
|
|
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
|
setSortClickCount(nextCount);
|
|
}
|
|
} else {
|
|
setSortKey(key);
|
|
setSortDir('asc');
|
|
setSortClickCount(1);
|
|
}
|
|
};
|
|
|
|
const displayedSongs = useMemo(() => {
|
|
if (!songs) return [];
|
|
const q = filterText.trim().toLowerCase();
|
|
if (!q && sortKey === 'natural') return songs;
|
|
let result = [...songs];
|
|
if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q));
|
|
if (sortKey !== 'natural') {
|
|
result.sort((a, b) => {
|
|
let av: string | number;
|
|
let bv: string | number;
|
|
switch (sortKey) {
|
|
case 'title': av = a.title; bv = b.title; break;
|
|
case 'artist': av = a.artist ?? ''; bv = b.artist ?? ''; break;
|
|
case 'album': av = a.album ?? ''; bv = b.album ?? ''; break;
|
|
case 'favorite':
|
|
av = starredSongs.has(a.id) ? 1 : 0;
|
|
bv = starredSongs.has(b.id) ? 1 : 0;
|
|
break;
|
|
case 'rating':
|
|
av = ratings[a.id] ?? userRatingOverrides[a.id] ?? a.userRating ?? 0;
|
|
bv = ratings[b.id] ?? userRatingOverrides[b.id] ?? b.userRating ?? 0;
|
|
break;
|
|
case 'duration': av = a.duration ?? 0; bv = b.duration ?? 0; break;
|
|
case 'playCount': av = a.playCount ?? 0; bv = b.playCount ?? 0; break;
|
|
case 'lastPlayed': av = a.played ? Date.parse(a.played) || 0 : 0; bv = b.played ? Date.parse(b.played) || 0 : 0; break;
|
|
case 'bpm': av = a.bpm ?? 0; bv = b.bpm ?? 0; break;
|
|
default: av = a.title; bv = b.title;
|
|
}
|
|
if (typeof av === 'number' && typeof bv === 'number') {
|
|
return sortDir === 'asc' ? av - bv : bv - av;
|
|
}
|
|
return sortDir === 'asc' ? (av as string).localeCompare(bv as string) : (bv as string).localeCompare(av as string);
|
|
});
|
|
}
|
|
return result;
|
|
}, [songs, filterText, sortKey, sortDir, starredSongs, ratings, userRatingOverrides]);
|
|
|
|
return { sortKey, sortDir, handleSort, displayedSongs };
|
|
}
|