mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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)
This commit is contained in:
committed by
GitHub
parent
603660d407
commit
efd85ffde3
@@ -11,6 +11,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { copyTextToClipboard } from '../utils/server/serverMagicString';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import { formatTrackTime } from '../utils/format/formatDuration';
|
||||
import { formatLastSeen } from '../utils/componentHelpers/userMgmtHelpers';
|
||||
import i18n from '../i18n';
|
||||
|
||||
function formatSize(bytes?: number): string | null {
|
||||
if (!bytes) return null;
|
||||
@@ -149,6 +151,9 @@ export default function SongInfoModal() {
|
||||
<Row label={t('songInfo.genre')} value={song.genre} />
|
||||
<Row label={t('songInfo.duration')} value={formatTrackTime(song.duration)} />
|
||||
<Row label={t('songInfo.track')} value={trackLabel} />
|
||||
<Row label={t('songInfo.bpm')} value={song.bpm} />
|
||||
<Row label={t('songInfo.playCount')} value={song.playCount} />
|
||||
<Row label={t('songInfo.lastPlayed')} value={song.played ? formatLastSeen(song.played, i18n.language, '—') : null} />
|
||||
|
||||
<Divider />
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import { usePreviewStore } from '../../store/previewStore';
|
||||
import StarRating from '../StarRating';
|
||||
import { codecLabel, type ColKey } from '../../utils/componentHelpers/albumTrackListHelpers';
|
||||
import { formatLongDuration } from '../../utils/format/formatDuration';
|
||||
import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers';
|
||||
import i18n from '../../i18n';
|
||||
|
||||
type ContextMenuFn = (
|
||||
x: number,
|
||||
@@ -191,6 +193,24 @@ export const TrackRow = React.memo(function TrackRow({
|
||||
{song.genre ?? '—'}
|
||||
</div>
|
||||
);
|
||||
case 'playCount':
|
||||
return (
|
||||
<div key="playCount" className="track-duration">
|
||||
{song.playCount ?? '—'}
|
||||
</div>
|
||||
);
|
||||
case 'lastPlayed':
|
||||
return (
|
||||
<div key="lastPlayed" className="track-genre">
|
||||
{song.played ? formatLastSeen(song.played, i18n.language, '—') : '—'}
|
||||
</div>
|
||||
);
|
||||
case 'bpm':
|
||||
return (
|
||||
<div key="bpm" className="track-duration">
|
||||
{song.bpm && song.bpm > 0 ? song.bpm : '—'}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -14,10 +14,12 @@ import { useDragDrop } from '../../contexts/DragDropContext';
|
||||
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
|
||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers';
|
||||
import i18n from '../../i18n';
|
||||
import { AddToPlaylistSubmenu } from '../ContextMenu';
|
||||
import StarRating from '../StarRating';
|
||||
|
||||
const SORTABLE_COLUMNS = new Set(['title', 'artist', 'album', 'rating', 'duration']);
|
||||
const SORTABLE_COLUMNS = new Set(['title', 'artist', 'album', 'rating', 'duration', 'playCount', 'lastPlayed', 'bpm']);
|
||||
|
||||
interface Props {
|
||||
visibleSongs: SubsonicSong[];
|
||||
@@ -352,6 +354,15 @@ export default function FavoritesSongsTracklist({
|
||||
{formatTrackTime(song.duration)}
|
||||
</div>
|
||||
);
|
||||
case 'playCount': return (
|
||||
<div key="playCount" className="track-duration">{song.playCount ?? '—'}</div>
|
||||
);
|
||||
case 'lastPlayed': return (
|
||||
<div key="lastPlayed" className="track-genre">{song.played ? formatLastSeen(song.played, i18n.language, '—') : '—'}</div>
|
||||
);
|
||||
case 'bpm': return (
|
||||
<div key="bpm" className="track-duration">{song.bpm && song.bpm > 0 ? song.bpm : '—'}</div>
|
||||
);
|
||||
case 'remove': return (
|
||||
<div key="remove" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<button className="btn-icon fav-remove-btn" data-tooltip={t('favorites.removeSong')} onClick={e => { e.stopPropagation(); removeSong(song.id); }} aria-label={t('favorites.removeSong')}>
|
||||
|
||||
@@ -14,12 +14,14 @@ import { useDragDrop } from '../../contexts/DragDropContext';
|
||||
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
|
||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||
import { codecLabel } from '../../utils/componentHelpers/playlistDetailHelpers';
|
||||
import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers';
|
||||
import i18n from '../../i18n';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
import type { PlaylistSortKey, PlaylistSortDir } from '../../utils/playlist/playlistDisplayedSongs';
|
||||
import StarRating from '../StarRating';
|
||||
import { AddToPlaylistSubmenu } from '../ContextMenu';
|
||||
|
||||
const PL_CENTERED = new Set(['favorite', 'rating', 'duration']);
|
||||
const PL_CENTERED = new Set(['favorite', 'rating', 'duration', 'playCount', 'bpm']);
|
||||
|
||||
interface Props {
|
||||
// Column config / picker
|
||||
@@ -412,6 +414,18 @@ export default function PlaylistTracklist({
|
||||
{(song.suffix || (showBitrate && song.bitRate)) && <span className="track-codec">{codecLabel(song, showBitrate)}</span>}
|
||||
</div>
|
||||
);
|
||||
case 'genre': return (
|
||||
<div key="genre" className="track-genre">{song.genre ?? '—'}</div>
|
||||
);
|
||||
case 'playCount': return (
|
||||
<div key="playCount" className="track-duration">{song.playCount ?? '—'}</div>
|
||||
);
|
||||
case 'lastPlayed': return (
|
||||
<div key="lastPlayed" className="track-genre">{song.played ? formatLastSeen(song.played, i18n.language, '—') : '—'}</div>
|
||||
);
|
||||
case 'bpm': return (
|
||||
<div key="bpm" className="track-duration">{song.bpm && song.bpm > 0 ? song.bpm : '—'}</div>
|
||||
);
|
||||
case 'delete': return (
|
||||
<div key="delete" className="playlist-row-delete-cell">
|
||||
<button className="playlist-row-delete-btn" onClick={e => { e.stopPropagation(); removeSong(realIdx); }} data-tooltip={t('playlists.removeSong')} data-tooltip-pos="left">
|
||||
|
||||
Reference in New Issue
Block a user