mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +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
@@ -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.
|
* 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
|
## Changed
|
||||||
|
|
||||||
### Backend — Cargo workspace with 5 domain crates (Rust refactor)
|
### Backend — Cargo workspace with 5 domain crates (Rust refactor)
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ export interface SubsonicSong {
|
|||||||
albumArtist?: string;
|
albumArtist?: string;
|
||||||
/** ISRC code when available (e.g., Navidrome) */
|
/** ISRC code when available (e.g., Navidrome) */
|
||||||
isrc?: string;
|
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?: {
|
replayGain?: {
|
||||||
trackGain?: number;
|
trackGain?: number;
|
||||||
albumGain?: number;
|
albumGain?: number;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { copyTextToClipboard } from '../utils/server/serverMagicString';
|
import { copyTextToClipboard } from '../utils/server/serverMagicString';
|
||||||
import { showToast } from '../utils/ui/toast';
|
import { showToast } from '../utils/ui/toast';
|
||||||
import { formatTrackTime } from '../utils/format/formatDuration';
|
import { formatTrackTime } from '../utils/format/formatDuration';
|
||||||
|
import { formatLastSeen } from '../utils/componentHelpers/userMgmtHelpers';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
|
||||||
function formatSize(bytes?: number): string | null {
|
function formatSize(bytes?: number): string | null {
|
||||||
if (!bytes) return 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.genre')} value={song.genre} />
|
||||||
<Row label={t('songInfo.duration')} value={formatTrackTime(song.duration)} />
|
<Row label={t('songInfo.duration')} value={formatTrackTime(song.duration)} />
|
||||||
<Row label={t('songInfo.track')} value={trackLabel} />
|
<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 />
|
<Divider />
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { usePreviewStore } from '../../store/previewStore';
|
|||||||
import StarRating from '../StarRating';
|
import StarRating from '../StarRating';
|
||||||
import { codecLabel, type ColKey } from '../../utils/componentHelpers/albumTrackListHelpers';
|
import { codecLabel, type ColKey } from '../../utils/componentHelpers/albumTrackListHelpers';
|
||||||
import { formatLongDuration } from '../../utils/format/formatDuration';
|
import { formatLongDuration } from '../../utils/format/formatDuration';
|
||||||
|
import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers';
|
||||||
|
import i18n from '../../i18n';
|
||||||
|
|
||||||
type ContextMenuFn = (
|
type ContextMenuFn = (
|
||||||
x: number,
|
x: number,
|
||||||
@@ -191,6 +193,24 @@ export const TrackRow = React.memo(function TrackRow({
|
|||||||
{song.genre ?? '—'}
|
{song.genre ?? '—'}
|
||||||
</div>
|
</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:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import { useDragDrop } from '../../contexts/DragDropContext';
|
|||||||
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
|
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
|
||||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||||
|
import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers';
|
||||||
|
import i18n from '../../i18n';
|
||||||
import { AddToPlaylistSubmenu } from '../ContextMenu';
|
import { AddToPlaylistSubmenu } from '../ContextMenu';
|
||||||
import StarRating from '../StarRating';
|
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 {
|
interface Props {
|
||||||
visibleSongs: SubsonicSong[];
|
visibleSongs: SubsonicSong[];
|
||||||
@@ -352,6 +354,15 @@ export default function FavoritesSongsTracklist({
|
|||||||
{formatTrackTime(song.duration)}
|
{formatTrackTime(song.duration)}
|
||||||
</div>
|
</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 (
|
case 'remove': return (
|
||||||
<div key="remove" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<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')}>
|
<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 { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
|
||||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||||
import { codecLabel } from '../../utils/componentHelpers/playlistDetailHelpers';
|
import { codecLabel } from '../../utils/componentHelpers/playlistDetailHelpers';
|
||||||
|
import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers';
|
||||||
|
import i18n from '../../i18n';
|
||||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||||
import type { PlaylistSortKey, PlaylistSortDir } from '../../utils/playlist/playlistDisplayedSongs';
|
import type { PlaylistSortKey, PlaylistSortDir } from '../../utils/playlist/playlistDisplayedSongs';
|
||||||
import StarRating from '../StarRating';
|
import StarRating from '../StarRating';
|
||||||
import { AddToPlaylistSubmenu } from '../ContextMenu';
|
import { AddToPlaylistSubmenu } from '../ContextMenu';
|
||||||
|
|
||||||
const PL_CENTERED = new Set(['favorite', 'rating', 'duration']);
|
const PL_CENTERED = new Set(['favorite', 'rating', 'duration', 'playCount', 'bpm']);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
// Column config / picker
|
// Column config / picker
|
||||||
@@ -412,6 +414,18 @@ export default function PlaylistTracklist({
|
|||||||
{(song.suffix || (showBitrate && song.bitRate)) && <span className="track-codec">{codecLabel(song, showBitrate)}</span>}
|
{(song.suffix || (showBitrate && song.bitRate)) && <span className="track-codec">{codecLabel(song, showBitrate)}</span>}
|
||||||
</div>
|
</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 (
|
case 'delete': return (
|
||||||
<div key="delete" className="playlist-row-delete-cell">
|
<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">
|
<button className="playlist-row-delete-btn" onClick={e => { e.stopPropagation(); removeSong(realIdx); }} data-tooltip={t('playlists.removeSong')} data-tooltip-pos="left">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
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 {
|
interface UseAlbumDetailSortArgs {
|
||||||
songs: SubsonicSong[] | undefined;
|
songs: SubsonicSong[] | undefined;
|
||||||
@@ -80,6 +80,9 @@ export function useAlbumDetailSort({
|
|||||||
bv = ratings[b.id] ?? userRatingOverrides[b.id] ?? b.userRating ?? 0;
|
bv = ratings[b.id] ?? userRatingOverrides[b.id] ?? b.userRating ?? 0;
|
||||||
break;
|
break;
|
||||||
case 'duration': av = a.duration ?? 0; bv = b.duration ?? 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;
|
default: av = a.title; bv = b.title;
|
||||||
}
|
}
|
||||||
if (typeof av === 'number' && typeof bv === 'number') {
|
if (typeof av === 'number' && typeof bv === 'number') {
|
||||||
|
|||||||
@@ -125,6 +125,15 @@ export function useFavoritesSongFiltering(deps: FavoritesSongFilteringDeps): Fav
|
|||||||
}
|
}
|
||||||
case 'duration':
|
case 'duration':
|
||||||
return multiplier * ((a.duration || 0) - (b.duration || 0));
|
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:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export const albumDetail = {
|
|||||||
trackAlbum: 'Album',
|
trackAlbum: 'Album',
|
||||||
trackArtist: 'Interpret',
|
trackArtist: 'Interpret',
|
||||||
trackGenre: 'Genre',
|
trackGenre: 'Genre',
|
||||||
|
trackPlayCount: 'Wiedergaben',
|
||||||
|
trackLastPlayed: 'Zuletzt gespielt',
|
||||||
|
trackBpm: 'BPM',
|
||||||
trackFormat: 'Format',
|
trackFormat: 'Format',
|
||||||
trackFavorite: 'Favorit',
|
trackFavorite: 'Favorit',
|
||||||
trackRating: 'Bewertung',
|
trackRating: 'Bewertung',
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export const songInfo = {
|
|||||||
genre: 'Genre',
|
genre: 'Genre',
|
||||||
duration: 'Länge',
|
duration: 'Länge',
|
||||||
track: 'Track',
|
track: 'Track',
|
||||||
|
bpm: 'BPM',
|
||||||
|
playCount: 'Wiedergaben',
|
||||||
|
lastPlayed: 'Zuletzt gespielt',
|
||||||
format: 'Format',
|
format: 'Format',
|
||||||
bitrate: 'Bitrate',
|
bitrate: 'Bitrate',
|
||||||
sampleRate: 'Abtastrate',
|
sampleRate: 'Abtastrate',
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export const albumDetail = {
|
|||||||
trackAlbum: 'Album',
|
trackAlbum: 'Album',
|
||||||
trackArtist: 'Artist',
|
trackArtist: 'Artist',
|
||||||
trackGenre: 'Genre',
|
trackGenre: 'Genre',
|
||||||
|
trackPlayCount: 'Plays',
|
||||||
|
trackLastPlayed: 'Last played',
|
||||||
|
trackBpm: 'BPM',
|
||||||
trackFormat: 'Format',
|
trackFormat: 'Format',
|
||||||
trackFavorite: 'Favorite',
|
trackFavorite: 'Favorite',
|
||||||
trackRating: 'Rating',
|
trackRating: 'Rating',
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export const songInfo = {
|
|||||||
genre: 'Genre',
|
genre: 'Genre',
|
||||||
duration: 'Duration',
|
duration: 'Duration',
|
||||||
track: 'Track',
|
track: 'Track',
|
||||||
|
bpm: 'BPM',
|
||||||
|
playCount: 'Play count',
|
||||||
|
lastPlayed: 'Last played',
|
||||||
format: 'Format',
|
format: 'Format',
|
||||||
bitrate: 'Bitrate',
|
bitrate: 'Bitrate',
|
||||||
sampleRate: 'Sample Rate',
|
sampleRate: 'Sample Rate',
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export const albumDetail = {
|
|||||||
trackAlbum: 'Álbum',
|
trackAlbum: 'Álbum',
|
||||||
trackArtist: 'Artista',
|
trackArtist: 'Artista',
|
||||||
trackGenre: 'Género',
|
trackGenre: 'Género',
|
||||||
|
trackPlayCount: 'Reproducciones',
|
||||||
|
trackLastPlayed: 'Última reproducción',
|
||||||
|
trackBpm: 'BPM',
|
||||||
trackFormat: 'Formato',
|
trackFormat: 'Formato',
|
||||||
trackFavorite: 'Favorito',
|
trackFavorite: 'Favorito',
|
||||||
trackRating: 'Calificación',
|
trackRating: 'Calificación',
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export const songInfo = {
|
|||||||
genre: 'Género',
|
genre: 'Género',
|
||||||
duration: 'Duración',
|
duration: 'Duración',
|
||||||
track: 'Pista',
|
track: 'Pista',
|
||||||
|
bpm: 'BPM',
|
||||||
|
playCount: 'Reproducciones',
|
||||||
|
lastPlayed: 'Última reproducción',
|
||||||
format: 'Formato',
|
format: 'Formato',
|
||||||
bitrate: 'Bitrate',
|
bitrate: 'Bitrate',
|
||||||
sampleRate: 'Frecuencia de Muestreo',
|
sampleRate: 'Frecuencia de Muestreo',
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export const albumDetail = {
|
|||||||
trackAlbum: 'Album',
|
trackAlbum: 'Album',
|
||||||
trackArtist: 'Artiste',
|
trackArtist: 'Artiste',
|
||||||
trackGenre: 'Genre',
|
trackGenre: 'Genre',
|
||||||
|
trackPlayCount: 'Lectures',
|
||||||
|
trackLastPlayed: 'Dernière lecture',
|
||||||
|
trackBpm: 'BPM',
|
||||||
trackFormat: 'Format',
|
trackFormat: 'Format',
|
||||||
trackFavorite: 'Favori',
|
trackFavorite: 'Favori',
|
||||||
trackRating: 'Note',
|
trackRating: 'Note',
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export const songInfo = {
|
|||||||
genre: 'Genre',
|
genre: 'Genre',
|
||||||
duration: 'Durée',
|
duration: 'Durée',
|
||||||
track: 'Piste',
|
track: 'Piste',
|
||||||
|
bpm: 'BPM',
|
||||||
|
playCount: 'Nombre de lectures',
|
||||||
|
lastPlayed: 'Dernière lecture',
|
||||||
format: 'Format',
|
format: 'Format',
|
||||||
bitrate: 'Débit',
|
bitrate: 'Débit',
|
||||||
sampleRate: 'Fréquence d\'échantillonnage',
|
sampleRate: 'Fréquence d\'échantillonnage',
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export const albumDetail = {
|
|||||||
trackAlbum: 'Album',
|
trackAlbum: 'Album',
|
||||||
trackArtist: 'Artist',
|
trackArtist: 'Artist',
|
||||||
trackGenre: 'Sjanger',
|
trackGenre: 'Sjanger',
|
||||||
|
trackPlayCount: 'Avspillinger',
|
||||||
|
trackLastPlayed: 'Sist spilt',
|
||||||
|
trackBpm: 'BPM',
|
||||||
trackFormat: 'Format',
|
trackFormat: 'Format',
|
||||||
trackFavorite: 'Favoritt',
|
trackFavorite: 'Favoritt',
|
||||||
trackRating: 'Vurdering',
|
trackRating: 'Vurdering',
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export const songInfo = {
|
|||||||
genre: 'Sjanger',
|
genre: 'Sjanger',
|
||||||
duration: 'Varighet',
|
duration: 'Varighet',
|
||||||
track: 'Spor',
|
track: 'Spor',
|
||||||
|
bpm: 'BPM',
|
||||||
|
playCount: 'Avspillinger',
|
||||||
|
lastPlayed: 'Sist spilt',
|
||||||
format: 'Format',
|
format: 'Format',
|
||||||
bitrate: 'Bitrate',
|
bitrate: 'Bitrate',
|
||||||
sampleRate: 'Samplingfrekvens',
|
sampleRate: 'Samplingfrekvens',
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export const albumDetail = {
|
|||||||
trackAlbum: 'Album',
|
trackAlbum: 'Album',
|
||||||
trackArtist: 'Artiest',
|
trackArtist: 'Artiest',
|
||||||
trackGenre: 'Genre',
|
trackGenre: 'Genre',
|
||||||
|
trackPlayCount: 'Weergaven',
|
||||||
|
trackLastPlayed: 'Laatst afgespeeld',
|
||||||
|
trackBpm: 'BPM',
|
||||||
trackFormat: 'Formaat',
|
trackFormat: 'Formaat',
|
||||||
trackFavorite: 'Favoriet',
|
trackFavorite: 'Favoriet',
|
||||||
trackRating: 'Beoordeling',
|
trackRating: 'Beoordeling',
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export const songInfo = {
|
|||||||
genre: 'Genre',
|
genre: 'Genre',
|
||||||
duration: 'Duur',
|
duration: 'Duur',
|
||||||
track: 'Track',
|
track: 'Track',
|
||||||
|
bpm: 'BPM',
|
||||||
|
playCount: 'Aantal weergaven',
|
||||||
|
lastPlayed: 'Laatst afgespeeld',
|
||||||
format: 'Formaat',
|
format: 'Formaat',
|
||||||
bitrate: 'Bitrate',
|
bitrate: 'Bitrate',
|
||||||
sampleRate: 'Samplefrequentie',
|
sampleRate: 'Samplefrequentie',
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export const albumDetail = {
|
|||||||
trackAlbum: 'Album',
|
trackAlbum: 'Album',
|
||||||
trackArtist: 'Artist',
|
trackArtist: 'Artist',
|
||||||
trackGenre: 'Gen',
|
trackGenre: 'Gen',
|
||||||
|
trackPlayCount: 'Redări',
|
||||||
|
trackLastPlayed: 'Ultima redare',
|
||||||
|
trackBpm: 'BPM',
|
||||||
trackFormat: 'Format',
|
trackFormat: 'Format',
|
||||||
trackFavorite: 'Favorit',
|
trackFavorite: 'Favorit',
|
||||||
trackRating: 'Rating',
|
trackRating: 'Rating',
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export const songInfo = {
|
|||||||
genre: 'Gen',
|
genre: 'Gen',
|
||||||
duration: 'Durată',
|
duration: 'Durată',
|
||||||
track: 'Piesă',
|
track: 'Piesă',
|
||||||
|
bpm: 'BPM',
|
||||||
|
playCount: 'Număr de redări',
|
||||||
|
lastPlayed: 'Ultima redare',
|
||||||
format: 'Format',
|
format: 'Format',
|
||||||
bitrate: 'Bitrate',
|
bitrate: 'Bitrate',
|
||||||
sampleRate: 'Rată de Sample',
|
sampleRate: 'Rată de Sample',
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export const albumDetail = {
|
|||||||
trackAlbum: 'Альбом',
|
trackAlbum: 'Альбом',
|
||||||
trackArtist: 'Исполнитель',
|
trackArtist: 'Исполнитель',
|
||||||
trackGenre: 'Жанр',
|
trackGenre: 'Жанр',
|
||||||
|
trackPlayCount: 'Прослушивания',
|
||||||
|
trackLastPlayed: 'Последнее воспроизведение',
|
||||||
|
trackBpm: 'BPM',
|
||||||
trackFormat: 'Формат',
|
trackFormat: 'Формат',
|
||||||
trackFavorite: 'Избранное',
|
trackFavorite: 'Избранное',
|
||||||
trackRating: 'Оценка',
|
trackRating: 'Оценка',
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export const songInfo = {
|
|||||||
genre: 'Жанр',
|
genre: 'Жанр',
|
||||||
duration: 'Длительность',
|
duration: 'Длительность',
|
||||||
track: 'Номер',
|
track: 'Номер',
|
||||||
|
bpm: 'BPM',
|
||||||
|
playCount: 'Количество прослушиваний',
|
||||||
|
lastPlayed: 'Последнее воспроизведение',
|
||||||
format: 'Формат',
|
format: 'Формат',
|
||||||
bitrate: 'Битрейт',
|
bitrate: 'Битрейт',
|
||||||
sampleRate: 'Частота',
|
sampleRate: 'Частота',
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export const albumDetail = {
|
|||||||
trackAlbum: '专辑',
|
trackAlbum: '专辑',
|
||||||
trackArtist: '艺术家',
|
trackArtist: '艺术家',
|
||||||
trackGenre: '流派',
|
trackGenre: '流派',
|
||||||
|
trackPlayCount: '播放次数',
|
||||||
|
trackLastPlayed: '上次播放',
|
||||||
|
trackBpm: 'BPM',
|
||||||
trackFormat: '格式',
|
trackFormat: '格式',
|
||||||
trackFavorite: '收藏',
|
trackFavorite: '收藏',
|
||||||
trackRating: '评分',
|
trackRating: '评分',
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export const songInfo = {
|
|||||||
genre: '流派',
|
genre: '流派',
|
||||||
duration: '时长',
|
duration: '时长',
|
||||||
track: '曲目',
|
track: '曲目',
|
||||||
|
bpm: 'BPM',
|
||||||
|
playCount: '播放次数',
|
||||||
|
lastPlayed: '上次播放',
|
||||||
format: '格式',
|
format: '格式',
|
||||||
bitrate: '比特率',
|
bitrate: '比特率',
|
||||||
sampleRate: '采样率',
|
sampleRate: '采样率',
|
||||||
|
|||||||
+12
-9
@@ -26,15 +26,18 @@ import { AddToPlaylistSubmenu } from '../components/ContextMenu';
|
|||||||
import GenreFilterBar from '../components/GenreFilterBar';
|
import GenreFilterBar from '../components/GenreFilterBar';
|
||||||
|
|
||||||
const FAV_COLUMNS: readonly ColDef[] = [
|
const FAV_COLUMNS: readonly ColDef[] = [
|
||||||
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, 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: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
|
||||||
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
|
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
|
||||||
{ key: 'album', i18nKey: 'trackAlbum', 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: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 120, required: false },
|
||||||
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, 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: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
|
||||||
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 80, required: false },
|
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 80, required: false },
|
||||||
{ key: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
|
{ 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();
|
const CURRENT_YEAR = new Date().getFullYear();
|
||||||
|
|||||||
@@ -55,15 +55,19 @@ import { usePlaylistDnDReorder } from '../hooks/usePlaylistDnDReorder';
|
|||||||
|
|
||||||
// ── Column configuration ──────────────────────────────────────────────────────
|
// ── Column configuration ──────────────────────────────────────────────────────
|
||||||
const PL_COLUMNS: readonly ColDef[] = [
|
const PL_COLUMNS: readonly ColDef[] = [
|
||||||
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, 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: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
|
||||||
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
|
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
|
||||||
{ key: 'album', i18nKey: 'trackAlbum', 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: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 120, required: false },
|
||||||
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
|
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
|
||||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
|
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
|
||||||
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
|
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
|
||||||
{ key: 'delete', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
|
{ 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() {
|
export default function PlaylistDetail() {
|
||||||
|
|||||||
@@ -8,23 +8,26 @@ export function codecLabel(song: { suffix?: string; bitRate?: number }, showBitr
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const COLUMNS: readonly ColDef[] = [
|
export const COLUMNS: readonly ColDef[] = [
|
||||||
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, 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: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
|
||||||
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
|
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
|
||||||
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
|
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
|
||||||
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, 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: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
|
||||||
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
|
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
|
||||||
{ key: 'genre', i18nKey: 'trackGenre', 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<ColKey>(['favorite', 'rating', 'duration']);
|
export const CENTERED_COLS = new Set<ColKey>(['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<ColKey | 'album'>(['title', 'artist', 'album', 'favorite', 'rating', 'duration']);
|
export const SORTABLE_COLS = new Set<ColKey | 'album'>(['title', 'artist', 'album', 'favorite', 'rating', 'duration', 'playCount', 'lastPlayed', 'bpm']);
|
||||||
|
|
||||||
export function isSortable(key: ColKey | string): key is SortKey {
|
export function isSortable(key: ColKey | string): key is SortKey {
|
||||||
return SORTABLE_COLS.has(key as ColKey);
|
return SORTABLE_COLS.has(key as ColKey);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
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 type PlaylistSortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
export interface DisplayedSongsOptions {
|
export interface DisplayedSongsOptions {
|
||||||
@@ -31,6 +31,9 @@ export function getDisplayedSongs(songs: SubsonicSong[], opts: DisplayedSongsOpt
|
|||||||
case 'favorite': av = effectiveStarred(a); bv = effectiveStarred(b); break;
|
case 'favorite': av = effectiveStarred(a); bv = effectiveStarred(b); break;
|
||||||
case 'rating': av = effectiveRating(a); bv = effectiveRating(b); break;
|
case 'rating': av = effectiveRating(a); bv = effectiveRating(b); break;
|
||||||
case 'duration': av = a.duration ?? 0; bv = b.duration ?? 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;
|
default: av = a.title; bv = b.title;
|
||||||
}
|
}
|
||||||
if (typeof av === 'number' && typeof bv === 'number') {
|
if (typeof av === 'number' && typeof bv === 'number') {
|
||||||
|
|||||||
@@ -71,7 +71,14 @@ export function useTracklistColumns(columns: readonly ColDef[], storageKey: stri
|
|||||||
const gridTemplate = useMemo(
|
const gridTemplate = useMemo(
|
||||||
() =>
|
() =>
|
||||||
visibleCols
|
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(' '),
|
.join(' '),
|
||||||
[visibleCols, colWidths],
|
[visibleCols, colWidths],
|
||||||
);
|
);
|
||||||
@@ -83,7 +90,11 @@ export function useTracklistColumns(columns: readonly ColDef[], storageKey: stri
|
|||||||
const gapPx = 12; // --space-3
|
const gapPx = 12; // --space-3
|
||||||
const boxPaddingH = 24; // var(--space-3) * 2
|
const boxPaddingH = 24; // var(--space-3) * 2
|
||||||
const colSum = visibleCols.reduce<number>(
|
const colSum = visibleCols.reduce<number>(
|
||||||
(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,
|
0,
|
||||||
);
|
);
|
||||||
const gaps = Math.max(0, visibleCols.length - 1) * gapPx;
|
const gaps = Math.max(0, visibleCols.length - 1) * gapPx;
|
||||||
|
|||||||
Reference in New Issue
Block a user