diff --git a/CHANGELOG.md b/CHANGELOG.md index ffcb487b..5e41d869 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -195,6 +195,13 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa * The queue header chip (total duration / remaining time / ETA finish clock) now persists across app restarts via a new `queueDurationDisplayMode` field on `authStore`. Corrupt or missing persisted values fall back to **total** on rehydrate, matching the existing `seekbarStyle` sanitizer pattern. +### Tracklists — Plays / Last played / BPM columns + Song Info rows + +**By [@Psychotoxical](https://github.com/Psychotoxical), suggested by jbigginswyl ([#516](https://github.com/Psychotoxical/psysonic/issues/516)), PR [#730](https://github.com/Psychotoxical/psysonic/pull/730)** + +* New opt-in columns **Plays**, **Last played**, and **BPM** on the Album / Playlist / Favorites tracklists, plus matching rows in the Song Info modal. Pulls Navidrome's existing `playCount` / `played` / `bpm` from the Subsonic response — no extra API calls. Genre column also added to the playlist tracklist for parity with Album + Favorites. BPM cells render `—` when Navidrome returns 0 (untagged file); Plays / Last played render `—` only when truly absent. +* Defensive fix in the tracklist column hook: visible columns with no saved width on an older prefs blob now fall back to the ColDef's default width instead of collapsing the row layout. + ## Changed ### Backend — Cargo workspace with 5 domain crates (Rust refactor) diff --git a/src/api/subsonicTypes.ts b/src/api/subsonicTypes.ts index 4255530f..e0b83c4a 100644 --- a/src/api/subsonicTypes.ts +++ b/src/api/subsonicTypes.ts @@ -65,6 +65,12 @@ export interface SubsonicSong { albumArtist?: string; /** ISRC code when available (e.g., Navidrome) */ isrc?: string; + /** Times the track has been played, surfaced by Navidrome's Subsonic API. */ + playCount?: number; + /** ISO datetime of the last play, surfaced by Navidrome (OpenSubsonic). */ + played?: string; + /** Beats per minute, surfaced by Navidrome when the tag is set on the file. */ + bpm?: number; replayGain?: { trackGain?: number; albumGain?: number; diff --git a/src/components/SongInfoModal.tsx b/src/components/SongInfoModal.tsx index 05955ec9..a95ff345 100644 --- a/src/components/SongInfoModal.tsx +++ b/src/components/SongInfoModal.tsx @@ -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() { + + + diff --git a/src/components/albumTrackList/TrackRow.tsx b/src/components/albumTrackList/TrackRow.tsx index c035bd92..89675866 100644 --- a/src/components/albumTrackList/TrackRow.tsx +++ b/src/components/albumTrackList/TrackRow.tsx @@ -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 ?? '—'} ); + case 'playCount': + return ( +
+ {song.playCount ?? '—'} +
+ ); + case 'lastPlayed': + return ( +
+ {song.played ? formatLastSeen(song.played, i18n.language, '—') : '—'} +
+ ); + case 'bpm': + return ( +
+ {song.bpm && song.bpm > 0 ? song.bpm : '—'} +
+ ); default: return null; } diff --git a/src/components/favorites/FavoritesSongsTracklist.tsx b/src/components/favorites/FavoritesSongsTracklist.tsx index 7884f6d8..682c36ce 100644 --- a/src/components/favorites/FavoritesSongsTracklist.tsx +++ b/src/components/favorites/FavoritesSongsTracklist.tsx @@ -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)} ); + case 'playCount': return ( +
{song.playCount ?? '—'}
+ ); + case 'lastPlayed': return ( +
{song.played ? formatLastSeen(song.played, i18n.language, '—') : '—'}
+ ); + case 'bpm': return ( +
{song.bpm && song.bpm > 0 ? song.bpm : '—'}
+ ); case 'remove': return (
); + case 'genre': return ( +
{song.genre ?? '—'}
+ ); + case 'playCount': return ( +
{song.playCount ?? '—'}
+ ); + case 'lastPlayed': return ( +
{song.played ? formatLastSeen(song.played, i18n.language, '—') : '—'}
+ ); + case 'bpm': return ( +
{song.bpm && song.bpm > 0 ? song.bpm : '—'}
+ ); case 'delete': return (