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 (
{ e.stopPropagation(); removeSong(song.id); }} aria-label={t('favorites.removeSong')}>
diff --git a/src/components/playlist/PlaylistTracklist.tsx b/src/components/playlist/PlaylistTracklist.tsx
index 3e2ab7c7..f1905d5d 100644
--- a/src/components/playlist/PlaylistTracklist.tsx
+++ b/src/components/playlist/PlaylistTracklist.tsx
@@ -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)) && {codecLabel(song, showBitrate)} }
);
+ 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 (
{ e.stopPropagation(); removeSong(realIdx); }} data-tooltip={t('playlists.removeSong')} data-tooltip-pos="left">
diff --git a/src/hooks/useAlbumDetailSort.ts b/src/hooks/useAlbumDetailSort.ts
index b41d2047..ae0a5db7 100644
--- a/src/hooks/useAlbumDetailSort.ts
+++ b/src/hooks/useAlbumDetailSort.ts
@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import type { SubsonicSong } from '../api/subsonicTypes';
-export type AlbumSortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration';
+export type AlbumSortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration' | 'playCount' | 'lastPlayed' | 'bpm';
interface UseAlbumDetailSortArgs {
songs: SubsonicSong[] | undefined;
@@ -80,6 +80,9 @@ export function useAlbumDetailSort({
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') {
diff --git a/src/hooks/useFavoritesSongFiltering.tsx b/src/hooks/useFavoritesSongFiltering.tsx
index 706cf3f0..9a4f7a0e 100644
--- a/src/hooks/useFavoritesSongFiltering.tsx
+++ b/src/hooks/useFavoritesSongFiltering.tsx
@@ -125,6 +125,15 @@ export function useFavoritesSongFiltering(deps: FavoritesSongFilteringDeps): Fav
}
case 'duration':
return multiplier * ((a.duration || 0) - (b.duration || 0));
+ case 'playCount':
+ return multiplier * ((a.playCount || 0) - (b.playCount || 0));
+ case 'lastPlayed': {
+ const ta = a.played ? Date.parse(a.played) || 0 : 0;
+ const tb = b.played ? Date.parse(b.played) || 0 : 0;
+ return multiplier * (ta - tb);
+ }
+ case 'bpm':
+ return multiplier * ((a.bpm || 0) - (b.bpm || 0));
default:
return 0;
}
diff --git a/src/locales/de/albumDetail.ts b/src/locales/de/albumDetail.ts
index 6b89ff2d..19ed5e23 100644
--- a/src/locales/de/albumDetail.ts
+++ b/src/locales/de/albumDetail.ts
@@ -27,6 +27,9 @@ export const albumDetail = {
trackAlbum: 'Album',
trackArtist: 'Interpret',
trackGenre: 'Genre',
+ trackPlayCount: 'Wiedergaben',
+ trackLastPlayed: 'Zuletzt gespielt',
+ trackBpm: 'BPM',
trackFormat: 'Format',
trackFavorite: 'Favorit',
trackRating: 'Bewertung',
diff --git a/src/locales/de/songInfo.ts b/src/locales/de/songInfo.ts
index 30d7e82f..d13c22d4 100644
--- a/src/locales/de/songInfo.ts
+++ b/src/locales/de/songInfo.ts
@@ -8,6 +8,9 @@ export const songInfo = {
genre: 'Genre',
duration: 'Länge',
track: 'Track',
+ bpm: 'BPM',
+ playCount: 'Wiedergaben',
+ lastPlayed: 'Zuletzt gespielt',
format: 'Format',
bitrate: 'Bitrate',
sampleRate: 'Abtastrate',
diff --git a/src/locales/en/albumDetail.ts b/src/locales/en/albumDetail.ts
index bd8e8d23..ec50aec1 100644
--- a/src/locales/en/albumDetail.ts
+++ b/src/locales/en/albumDetail.ts
@@ -27,6 +27,9 @@ export const albumDetail = {
trackAlbum: 'Album',
trackArtist: 'Artist',
trackGenre: 'Genre',
+ trackPlayCount: 'Plays',
+ trackLastPlayed: 'Last played',
+ trackBpm: 'BPM',
trackFormat: 'Format',
trackFavorite: 'Favorite',
trackRating: 'Rating',
diff --git a/src/locales/en/songInfo.ts b/src/locales/en/songInfo.ts
index 5d91bf4e..06472e48 100644
--- a/src/locales/en/songInfo.ts
+++ b/src/locales/en/songInfo.ts
@@ -8,6 +8,9 @@ export const songInfo = {
genre: 'Genre',
duration: 'Duration',
track: 'Track',
+ bpm: 'BPM',
+ playCount: 'Play count',
+ lastPlayed: 'Last played',
format: 'Format',
bitrate: 'Bitrate',
sampleRate: 'Sample Rate',
diff --git a/src/locales/es/albumDetail.ts b/src/locales/es/albumDetail.ts
index d76135ee..7870dfef 100644
--- a/src/locales/es/albumDetail.ts
+++ b/src/locales/es/albumDetail.ts
@@ -27,6 +27,9 @@ export const albumDetail = {
trackAlbum: 'Álbum',
trackArtist: 'Artista',
trackGenre: 'Género',
+ trackPlayCount: 'Reproducciones',
+ trackLastPlayed: 'Última reproducción',
+ trackBpm: 'BPM',
trackFormat: 'Formato',
trackFavorite: 'Favorito',
trackRating: 'Calificación',
diff --git a/src/locales/es/songInfo.ts b/src/locales/es/songInfo.ts
index b51fcaa1..73ae2dca 100644
--- a/src/locales/es/songInfo.ts
+++ b/src/locales/es/songInfo.ts
@@ -8,6 +8,9 @@ export const songInfo = {
genre: 'Género',
duration: 'Duración',
track: 'Pista',
+ bpm: 'BPM',
+ playCount: 'Reproducciones',
+ lastPlayed: 'Última reproducción',
format: 'Formato',
bitrate: 'Bitrate',
sampleRate: 'Frecuencia de Muestreo',
diff --git a/src/locales/fr/albumDetail.ts b/src/locales/fr/albumDetail.ts
index 551c1808..f6af8b34 100644
--- a/src/locales/fr/albumDetail.ts
+++ b/src/locales/fr/albumDetail.ts
@@ -27,6 +27,9 @@ export const albumDetail = {
trackAlbum: 'Album',
trackArtist: 'Artiste',
trackGenre: 'Genre',
+ trackPlayCount: 'Lectures',
+ trackLastPlayed: 'Dernière lecture',
+ trackBpm: 'BPM',
trackFormat: 'Format',
trackFavorite: 'Favori',
trackRating: 'Note',
diff --git a/src/locales/fr/songInfo.ts b/src/locales/fr/songInfo.ts
index 81ead9d1..3397e730 100644
--- a/src/locales/fr/songInfo.ts
+++ b/src/locales/fr/songInfo.ts
@@ -8,6 +8,9 @@ export const songInfo = {
genre: 'Genre',
duration: 'Durée',
track: 'Piste',
+ bpm: 'BPM',
+ playCount: 'Nombre de lectures',
+ lastPlayed: 'Dernière lecture',
format: 'Format',
bitrate: 'Débit',
sampleRate: 'Fréquence d\'échantillonnage',
diff --git a/src/locales/nb/albumDetail.ts b/src/locales/nb/albumDetail.ts
index 05ced5c7..d0d5a5db 100644
--- a/src/locales/nb/albumDetail.ts
+++ b/src/locales/nb/albumDetail.ts
@@ -27,6 +27,9 @@ export const albumDetail = {
trackAlbum: 'Album',
trackArtist: 'Artist',
trackGenre: 'Sjanger',
+ trackPlayCount: 'Avspillinger',
+ trackLastPlayed: 'Sist spilt',
+ trackBpm: 'BPM',
trackFormat: 'Format',
trackFavorite: 'Favoritt',
trackRating: 'Vurdering',
diff --git a/src/locales/nb/songInfo.ts b/src/locales/nb/songInfo.ts
index 774beecc..59c749c6 100644
--- a/src/locales/nb/songInfo.ts
+++ b/src/locales/nb/songInfo.ts
@@ -8,6 +8,9 @@ export const songInfo = {
genre: 'Sjanger',
duration: 'Varighet',
track: 'Spor',
+ bpm: 'BPM',
+ playCount: 'Avspillinger',
+ lastPlayed: 'Sist spilt',
format: 'Format',
bitrate: 'Bitrate',
sampleRate: 'Samplingfrekvens',
diff --git a/src/locales/nl/albumDetail.ts b/src/locales/nl/albumDetail.ts
index 60abf0fe..01e16b7a 100644
--- a/src/locales/nl/albumDetail.ts
+++ b/src/locales/nl/albumDetail.ts
@@ -27,6 +27,9 @@ export const albumDetail = {
trackAlbum: 'Album',
trackArtist: 'Artiest',
trackGenre: 'Genre',
+ trackPlayCount: 'Weergaven',
+ trackLastPlayed: 'Laatst afgespeeld',
+ trackBpm: 'BPM',
trackFormat: 'Formaat',
trackFavorite: 'Favoriet',
trackRating: 'Beoordeling',
diff --git a/src/locales/nl/songInfo.ts b/src/locales/nl/songInfo.ts
index 74c0d3cf..e0117bc8 100644
--- a/src/locales/nl/songInfo.ts
+++ b/src/locales/nl/songInfo.ts
@@ -8,6 +8,9 @@ export const songInfo = {
genre: 'Genre',
duration: 'Duur',
track: 'Track',
+ bpm: 'BPM',
+ playCount: 'Aantal weergaven',
+ lastPlayed: 'Laatst afgespeeld',
format: 'Formaat',
bitrate: 'Bitrate',
sampleRate: 'Samplefrequentie',
diff --git a/src/locales/ro/albumDetail.ts b/src/locales/ro/albumDetail.ts
index 1c493e80..74358e9d 100644
--- a/src/locales/ro/albumDetail.ts
+++ b/src/locales/ro/albumDetail.ts
@@ -27,6 +27,9 @@ export const albumDetail = {
trackAlbum: 'Album',
trackArtist: 'Artist',
trackGenre: 'Gen',
+ trackPlayCount: 'Redări',
+ trackLastPlayed: 'Ultima redare',
+ trackBpm: 'BPM',
trackFormat: 'Format',
trackFavorite: 'Favorit',
trackRating: 'Rating',
diff --git a/src/locales/ro/songInfo.ts b/src/locales/ro/songInfo.ts
index 3bf88a51..c8457cc5 100644
--- a/src/locales/ro/songInfo.ts
+++ b/src/locales/ro/songInfo.ts
@@ -8,6 +8,9 @@ export const songInfo = {
genre: 'Gen',
duration: 'Durată',
track: 'Piesă',
+ bpm: 'BPM',
+ playCount: 'Număr de redări',
+ lastPlayed: 'Ultima redare',
format: 'Format',
bitrate: 'Bitrate',
sampleRate: 'Rată de Sample',
diff --git a/src/locales/ru/albumDetail.ts b/src/locales/ru/albumDetail.ts
index 67596d29..da955649 100644
--- a/src/locales/ru/albumDetail.ts
+++ b/src/locales/ru/albumDetail.ts
@@ -28,6 +28,9 @@ export const albumDetail = {
trackAlbum: 'Альбом',
trackArtist: 'Исполнитель',
trackGenre: 'Жанр',
+ trackPlayCount: 'Прослушивания',
+ trackLastPlayed: 'Последнее воспроизведение',
+ trackBpm: 'BPM',
trackFormat: 'Формат',
trackFavorite: 'Избранное',
trackRating: 'Оценка',
diff --git a/src/locales/ru/songInfo.ts b/src/locales/ru/songInfo.ts
index 34aea49c..716a86b9 100644
--- a/src/locales/ru/songInfo.ts
+++ b/src/locales/ru/songInfo.ts
@@ -8,6 +8,9 @@ export const songInfo = {
genre: 'Жанр',
duration: 'Длительность',
track: 'Номер',
+ bpm: 'BPM',
+ playCount: 'Количество прослушиваний',
+ lastPlayed: 'Последнее воспроизведение',
format: 'Формат',
bitrate: 'Битрейт',
sampleRate: 'Частота',
diff --git a/src/locales/zh/albumDetail.ts b/src/locales/zh/albumDetail.ts
index f9af5789..ccd57faa 100644
--- a/src/locales/zh/albumDetail.ts
+++ b/src/locales/zh/albumDetail.ts
@@ -27,6 +27,9 @@ export const albumDetail = {
trackAlbum: '专辑',
trackArtist: '艺术家',
trackGenre: '流派',
+ trackPlayCount: '播放次数',
+ trackLastPlayed: '上次播放',
+ trackBpm: 'BPM',
trackFormat: '格式',
trackFavorite: '收藏',
trackRating: '评分',
diff --git a/src/locales/zh/songInfo.ts b/src/locales/zh/songInfo.ts
index 94e13c76..1c8facf6 100644
--- a/src/locales/zh/songInfo.ts
+++ b/src/locales/zh/songInfo.ts
@@ -8,6 +8,9 @@ export const songInfo = {
genre: '流派',
duration: '时长',
track: '曲目',
+ bpm: 'BPM',
+ playCount: '播放次数',
+ lastPlayed: '上次播放',
format: '格式',
bitrate: '比特率',
sampleRate: '采样率',
diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx
index 7d0826ff..ee066ffe 100644
--- a/src/pages/Favorites.tsx
+++ b/src/pages/Favorites.tsx
@@ -26,15 +26,18 @@ import { AddToPlaylistSubmenu } from '../components/ContextMenu';
import GenreFilterBar from '../components/GenreFilterBar';
const FAV_COLUMNS: readonly ColDef[] = [
- { key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
- { key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
- { key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
- { key: 'album', i18nKey: 'trackAlbum', minWidth: 80, defaultWidth: 180, required: false },
- { key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 120, required: false },
- { key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
- { key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
- { key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 80, required: false },
- { key: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
+ { key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
+ { key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
+ { key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
+ { key: 'album', i18nKey: 'trackAlbum', minWidth: 80, defaultWidth: 180, required: false },
+ { key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 120, required: false },
+ { key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
+ { key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
+ { key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 80, required: false },
+ { key: 'playCount', i18nKey: 'trackPlayCount', minWidth: 60, defaultWidth: 80, required: false },
+ { key: 'lastPlayed', i18nKey: 'trackLastPlayed', minWidth: 90, defaultWidth: 130, required: false },
+ { key: 'bpm', i18nKey: 'trackBpm', minWidth: 50, defaultWidth: 70, required: false },
+ { key: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
];
const CURRENT_YEAR = new Date().getFullYear();
diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx
index 53756e99..e48027e2 100644
--- a/src/pages/PlaylistDetail.tsx
+++ b/src/pages/PlaylistDetail.tsx
@@ -55,15 +55,19 @@ import { usePlaylistDnDReorder } from '../hooks/usePlaylistDnDReorder';
// ── Column configuration ──────────────────────────────────────────────────────
const PL_COLUMNS: readonly ColDef[] = [
- { key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
- { key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
- { key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
- { key: 'album', i18nKey: 'trackAlbum', minWidth: 80, defaultWidth: 180, required: false },
- { key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
- { key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
- { key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
- { key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
- { key: 'delete', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
+ { key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
+ { key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
+ { key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
+ { key: 'album', i18nKey: 'trackAlbum', minWidth: 80, defaultWidth: 180, required: false },
+ { key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 120, required: false },
+ { key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
+ { key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
+ { key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
+ { key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
+ { key: 'playCount', i18nKey: 'trackPlayCount', minWidth: 60, defaultWidth: 80, required: false },
+ { key: 'lastPlayed', i18nKey: 'trackLastPlayed', minWidth: 90, defaultWidth: 130, required: false },
+ { key: 'bpm', i18nKey: 'trackBpm', minWidth: 50, defaultWidth: 70, required: false },
+ { key: 'delete', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
];
export default function PlaylistDetail() {
diff --git a/src/utils/componentHelpers/albumTrackListHelpers.ts b/src/utils/componentHelpers/albumTrackListHelpers.ts
index dce0396b..caf04220 100644
--- a/src/utils/componentHelpers/albumTrackListHelpers.ts
+++ b/src/utils/componentHelpers/albumTrackListHelpers.ts
@@ -8,23 +8,26 @@ export function codecLabel(song: { suffix?: string; bitRate?: number }, showBitr
}
export const COLUMNS: readonly ColDef[] = [
- { key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
- { key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
- { key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
- { key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
- { key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
- { key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
- { key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
- { key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 90, required: false },
+ { key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
+ { key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
+ { key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
+ { key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
+ { key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
+ { key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
+ { key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
+ { key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 90, required: false },
+ { key: 'playCount', i18nKey: 'trackPlayCount', minWidth: 60, defaultWidth: 80, required: false },
+ { key: 'lastPlayed', i18nKey: 'trackLastPlayed', minWidth: 90, defaultWidth: 130, required: false },
+ { key: 'bpm', i18nKey: 'trackBpm', minWidth: 50, defaultWidth: 70, required: false },
];
-export type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre';
+export type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre' | 'playCount' | 'lastPlayed' | 'bpm';
-export const CENTERED_COLS = new Set(['favorite', 'rating', 'duration']);
+export const CENTERED_COLS = new Set(['favorite', 'rating', 'duration', 'playCount', 'bpm']);
-export type SortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration';
+export type SortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration' | 'playCount' | 'lastPlayed' | 'bpm';
-export const SORTABLE_COLS = new Set(['title', 'artist', 'album', 'favorite', 'rating', 'duration']);
+export const SORTABLE_COLS = new Set(['title', 'artist', 'album', 'favorite', 'rating', 'duration', 'playCount', 'lastPlayed', 'bpm']);
export function isSortable(key: ColKey | string): key is SortKey {
return SORTABLE_COLS.has(key as ColKey);
diff --git a/src/utils/playlist/playlistDisplayedSongs.ts b/src/utils/playlist/playlistDisplayedSongs.ts
index 99581024..e8e6d7ee 100644
--- a/src/utils/playlist/playlistDisplayedSongs.ts
+++ b/src/utils/playlist/playlistDisplayedSongs.ts
@@ -1,6 +1,6 @@
import type { SubsonicSong } from '../../api/subsonicTypes';
-export type PlaylistSortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration';
+export type PlaylistSortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration' | 'playCount' | 'lastPlayed' | 'bpm';
export type PlaylistSortDir = 'asc' | 'desc';
export interface DisplayedSongsOptions {
@@ -31,6 +31,9 @@ export function getDisplayedSongs(songs: SubsonicSong[], opts: DisplayedSongsOpt
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') {
diff --git a/src/utils/useTracklistColumns.ts b/src/utils/useTracklistColumns.ts
index a29ccf24..0e5b372c 100644
--- a/src/utils/useTracklistColumns.ts
+++ b/src/utils/useTracklistColumns.ts
@@ -71,7 +71,14 @@ export function useTracklistColumns(columns: readonly ColDef[], storageKey: stri
const gridTemplate = useMemo(
() =>
visibleCols
- .map(c => (c.flex ? `minmax(${c.minWidth}px, 1fr)` : `${colWidths[c.key]}px`))
+ .map(c => {
+ if (c.flex) return `minmax(${c.minWidth}px, 1fr)`;
+ // Defensive fallback: a column added since the last persist would have
+ // no saved width, leaving the grid template with `undefinedpx` and
+ // collapsing the row visually until the user resets defaults.
+ const w = colWidths[c.key];
+ return `${typeof w === 'number' && w > 0 ? w : c.defaultWidth}px`;
+ })
.join(' '),
[visibleCols, colWidths],
);
@@ -83,7 +90,11 @@ export function useTracklistColumns(columns: readonly ColDef[], storageKey: stri
const gapPx = 12; // --space-3
const boxPaddingH = 24; // var(--space-3) * 2
const colSum = visibleCols.reduce(
- (s, c) => s + (c.flex ? c.minWidth : colWidths[c.key]),
+ (s, c) => {
+ if (c.flex) return s + c.minWidth;
+ const w = colWidths[c.key];
+ return s + (typeof w === 'number' && w > 0 ? w : c.defaultWidth);
+ },
0,
);
const gaps = Math.max(0, visibleCols.length - 1) * gapPx;