Files
Psychotoxical-psysonic/src/utils/playlist/playlistDisplayedSongs.ts
T
Frank Stellmacher efd85ffde3 feat(tracklist): play count, last played, BPM columns + Song Info rows (#730)
* 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)
2026-05-15 22:01:05 +02:00

47 lines
2.3 KiB
TypeScript

import type { SubsonicSong } from '../../api/subsonicTypes';
export type PlaylistSortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration' | 'playCount' | 'lastPlayed' | 'bpm';
export type PlaylistSortDir = 'asc' | 'desc';
export interface DisplayedSongsOptions {
filterText: string;
sortKey: PlaylistSortKey;
sortDir: PlaylistSortDir;
ratings: Record<string, number>;
userRatingOverrides: Record<string, number>;
starredOverrides: Record<string, boolean>;
starredSongs: Set<string>;
}
export function getDisplayedSongs(songs: SubsonicSong[], opts: DisplayedSongsOptions): SubsonicSong[] {
const q = opts.filterText.trim().toLowerCase();
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 !== 'natural') {
result.sort((a, b) => {
let av: string | number;
let bv: string | number;
const effectiveRating = (s: SubsonicSong) => opts.ratings[s.id] ?? opts.userRatingOverrides[s.id] ?? s.userRating ?? 0;
const effectiveStarred = (s: SubsonicSong) => (s.id in opts.starredOverrides ? opts.starredOverrides[s.id] : opts.starredSongs.has(s.id)) ? 1 : 0;
switch (opts.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 = effectiveStarred(a); bv = effectiveStarred(b); break;
case 'rating': av = effectiveRating(a); bv = effectiveRating(b); 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 opts.sortDir === 'asc' ? av - bv : bv - av;
}
return opts.sortDir === 'asc' ? (av as string).localeCompare(bv as string) : (bv as string).localeCompare(av as string);
});
}
return result;
}