Files
psysonic/src/components/SongInfoModal.tsx
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

197 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getSong } from '../api/subsonicLibrary';
import type { SubsonicSong } from '../api/subsonicTypes';
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { ndGetSongPath } from '../api/navidromeAdmin';
import { useAuthStore } from '../store/authStore';
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;
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(2)} MB`;
return `${(bytes / 1_000).toFixed(0)} KB`;
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
if (value === null || value === undefined || value === '' || value === '—') return null;
return (
<tr>
<td className="song-info-label">{label}</td>
<td className="song-info-value">{value}</td>
</tr>
);
}
/** Title / Artist / Album: double-click the value cell to copy plain text. */
function CopyableFieldRow({ label, text }: { label: string; text: string | null | undefined }) {
const { t } = useTranslation();
if (!text || text === '—') return null;
const onDoubleClick = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const ok = await copyTextToClipboard(text);
if (ok) showToast(t('orbit.tooltipCopied'), 2000, 'info');
else showToast(t('contextMenu.shareCopyFailed'), 3500, 'error');
};
return (
<tr>
<td className="song-info-label">{label}</td>
<td className="song-info-value song-info-value--no-select" onDoubleClick={onDoubleClick}>
{text}
</td>
</tr>
);
}
function Divider() {
return <tr><td colSpan={2} className="song-info-divider" /></tr>;
}
export default function SongInfoModal() {
const { t } = useTranslation();
const { songInfoModal, closeSongInfo } = usePlayerStore(
useShallow(s => ({ songInfoModal: s.songInfoModal, closeSongInfo: s.closeSongInfo }))
);
const [song, setSong] = useState<SubsonicSong | null>(null);
const [loading, setLoading] = useState(false);
// Absolute filesystem path resolved via Navidrome's native API in parallel
// with the Subsonic getSong call. Subsonic only ever returns a relative
// path (or none on Navidrome); the native endpoint is what Feishin and the
// Navidrome web client use to surface the full server-side location.
const [absolutePath, setAbsolutePath] = useState<string | null>(null);
useEffect(() => {
if (!songInfoModal.isOpen || !songInfoModal.songId) {
setSong(null);
setAbsolutePath(null);
return;
}
let cancelled = false;
setLoading(true);
setAbsolutePath(null);
const songId = songInfoModal.songId;
getSong(songId).then(s => {
if (!cancelled) { setSong(s); setLoading(false); }
});
// Try the native API in parallel; only when the active server is Navidrome
// and we have credentials. Failures are silent — modal falls back to
// whatever the Subsonic `path` field carried (typically nothing).
const auth = useAuthStore.getState();
const sid = auth.activeServerId;
const profile = sid ? auth.servers.find(p => p.id === sid) : null;
const identity = sid ? auth.subsonicServerIdentityByServer[sid] : undefined;
const isNavidrome = identity?.type?.trim().toLowerCase() === 'navidrome';
if (isNavidrome && profile?.url && profile.username && profile.password) {
const serverUrl = (profile.url.startsWith('http') ? profile.url : `http://${profile.url}`).replace(/\/$/, '');
ndGetSongPath(serverUrl, profile.username, profile.password, songId).then(p => {
if (!cancelled && p) setAbsolutePath(p);
});
}
return () => { cancelled = true; };
}, [songInfoModal.isOpen, songInfoModal.songId]);
useEffect(() => {
if (!songInfoModal.isOpen) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') closeSongInfo(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [songInfoModal.isOpen, closeSongInfo]);
if (!songInfoModal.isOpen) return null;
const channels = song?.channelCount === 1
? t('songInfo.mono')
: song?.channelCount === 2
? t('songInfo.stereo')
: song?.channelCount
? `${song.channelCount} ch`
: null;
const trackLabel = song?.discNumber && song.discNumber > 1
? `${song.discNumber} ${song.track}`
: song?.track != null
? String(song.track)
: null;
const hasReplayGain = song?.replayGain &&
(song.replayGain.trackGain !== undefined || song.replayGain.albumGain !== undefined);
return createPortal(
<>
<div className="song-info-backdrop" onClick={closeSongInfo} />
<div className="song-info-modal" role="dialog" aria-modal="true" aria-label={t('songInfo.title')}>
<div className="song-info-header">
<span className="song-info-title">{t('songInfo.title')}</span>
<button className="btn btn-ghost song-info-close" onClick={closeSongInfo} aria-label="Close">
<X size={16} />
</button>
</div>
<div className="song-info-body">
{loading && <div className="song-info-loading">{t('common.loading')}</div>}
{!loading && song && (
<table className="song-info-table">
<tbody>
<CopyableFieldRow label={t('songInfo.songTitle')} text={song.title} />
<CopyableFieldRow label={t('songInfo.artist')} text={song.artist} />
<CopyableFieldRow label={t('songInfo.album')} text={song.album} />
{song.albumArtist && song.albumArtist !== song.artist && (
<Row label={t('songInfo.albumArtist')} value={song.albumArtist} />
)}
<Row label={t('songInfo.year')} value={song.year} />
<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 />
<Row label={t('songInfo.format')} value={[song.suffix?.toUpperCase(), song.contentType].filter(Boolean).join(' · ') || null} />
<Row label={t('songInfo.bitrate')} value={song.bitRate ? `${song.bitRate} kbps` : null} />
<Row label={t('songInfo.sampleRate')} value={song.samplingRate ? `${(song.samplingRate / 1000).toFixed(1)} kHz` : null} />
<Row label={t('songInfo.bitDepth')} value={song.bitDepth ? `${song.bitDepth} bit` : null} />
<Row label={t('songInfo.channels')} value={channels} />
<Row label={t('songInfo.fileSize')} value={formatSize(song.size)} />
{(absolutePath || song.path) && (
<>
<Divider />
<Row label={t('songInfo.path')} value={<span className="song-info-path">{absolutePath ?? song.path}</span>} />
</>
)}
{hasReplayGain && (
<>
<Divider />
{song.replayGain!.trackGain !== undefined && (
<Row label={t('songInfo.replayGainTrack')} value={`${song.replayGain!.trackGain >= 0 ? '+' : ''}${song.replayGain!.trackGain.toFixed(2)} dB`} />
)}
{song.replayGain!.albumGain !== undefined && (
<Row label={t('songInfo.replayGainAlbum')} value={`${song.replayGain!.albumGain >= 0 ? '+' : ''}${song.replayGain!.albumGain.toFixed(2)} dB`} />
)}
{song.replayGain!.trackPeak !== undefined && (
<Row label={t('songInfo.replayGainPeak')} value={song.replayGain!.trackPeak.toFixed(6)} />
)}
</>
)}
</tbody>
</table>
)}
</div>
</div>
</>,
document.body
);
}