Files
psysonic/src/components/AlbumTrackList.tsx
T
Frank Stellmacher 6595c146a3 feat(album): show OpenSubsonic disc subtitles after the CD heading (#753)
* feat(album): show OpenSubsonic disc subtitles after the CD heading

Multi-disc albums in OpenSubsonic / Navidrome carry a per-disc
subtitle (`discTitles`) — e.g. "Sessions" on CD 3 of a deluxe
edition. AlbumTrackList only rendered "CD N" and dropped the
subtitle, so users couldn't tell two discs apart unless they read
the track names.

* `SubsonicAlbum.discTitles` typed; `getAlbum` forwards it as-is.
* `AlbumTrackList` and `AlbumTrackListMobile` take a discTitleByNum
  map and render the subtitle in the disc separator after "CD N".
* Heading bumped slightly (13 → 15 px, icon 16 → 18 px) so the disc
  separator stays legible next to the new subtitle.

* docs(changelog): note disc subtitles after CD heading (#753)
2026-05-17 15:29:29 +02:00

198 lines
6.7 KiB
TypeScript

import type { SubsonicSong } from '../api/subsonicTypes';
import type { Track } from '../store/playerStoreTypes';
import React, { useState, useEffect } from 'react';
import { useTracklistColumns } from '../utils/useTracklistColumns';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../hooks/useIsMobile';
import { useSelectionStore } from '../store/selectionStore';
import {
COLUMNS,
type SortKey,
} from '../utils/componentHelpers/albumTrackListHelpers';
import { useAlbumTrackListSelection } from '../hooks/useAlbumTrackListSelection';
import { TrackRow } from './albumTrackList/TrackRow';
import { AlbumTrackListMobile } from './albumTrackList/AlbumTrackListMobile';
import { TracklistColumnPicker } from './albumTrackList/TracklistColumnPicker';
import { TracklistHeaderRow } from './albumTrackList/TracklistHeaderRow';
export type { SortKey } from '../utils/componentHelpers/albumTrackListHelpers';
interface AlbumTrackListProps {
songs: SubsonicSong[];
/** Per-disc subtitles from the album payload, rendered after "CD N". */
discTitles?: { disc: number; title: string }[];
sorted?: boolean;
hasVariousArtists: boolean;
currentTrack: Track | null;
isPlaying: boolean;
ratings: Record<string, number>;
userRatingOverrides: Record<string, number>;
starredSongs: Set<string>;
onPlaySong: (song: SubsonicSong) => void;
/** Optional dbl-click handler — currently set only in Orbit mode so the list knows to bind it. */
onDoubleClickSong?: (song: SubsonicSong) => void;
onRate: (songId: string, rating: number) => void;
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => void;
sortKey?: SortKey;
sortDir?: 'asc' | 'desc';
onSort?: (key: SortKey) => void;
}
// ── AlbumTrackList ────────────────────────────────────────────────────────────
export default function AlbumTrackList({
songs,
discTitles,
sorted,
hasVariousArtists: _hasVariousArtists,
currentTrack,
isPlaying,
ratings,
userRatingOverrides,
starredSongs,
onPlaySong,
onDoubleClickSong,
onRate,
onToggleSongStar,
onContextMenu,
sortKey,
sortDir,
onSort,
}: AlbumTrackListProps) {
const { t } = useTranslation();
const isMobile = useIsMobile();
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const {
colVisible, visibleCols, gridStyle,
startResize, toggleColumn, resetColumns,
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns');
const {
inSelectMode, allSelected, onToggleSelect, onDragStart, toggleAll,
} = useAlbumTrackListSelection({ songs, tracklistRef });
useEffect(() => {
if (!contextMenuOpen) setContextMenuSongId(null);
}, [contextMenuOpen]);
// ── Disc grouping ─────────────────────────────────────────────────────────
const discs = new Map<number, SubsonicSong[]>();
if (!sorted) {
songs.forEach(song => {
const disc = song.discNumber ?? 1;
if (!discs.has(disc)) discs.set(disc, []);
discs.get(disc)!.push(song);
});
} else {
discs.set(1, songs as SubsonicSong[]);
}
const discNums = sorted ? [1] : Array.from(discs.keys()).sort((a, b) => a - b);
const isMultiDisc = !sorted && discNums.length > 1;
const discTitleByNum = new Map<number, string>(
(discTitles ?? []).filter(d => d.title?.trim()).map(d => [d.disc, d.title.trim()]),
);
const currentTrackId = currentTrack?.id ?? null;
if (isMobile) {
return (
<AlbumTrackListMobile
discNums={discNums}
discs={discs}
discTitleByNum={discTitleByNum}
isMultiDisc={isMultiDisc}
currentTrackId={currentTrackId}
isPlaying={isPlaying}
contextMenuSongId={contextMenuSongId}
setContextMenuSongId={setContextMenuSongId}
onPlaySong={onPlaySong}
onContextMenu={onContextMenu}
/>
);
}
return (
<>
<TracklistColumnPicker
pickerRef={pickerRef}
pickerOpen={pickerOpen}
setPickerOpen={setPickerOpen}
colVisible={colVisible}
toggleColumn={toggleColumn}
resetColumns={resetColumns}
t={t}
/>
<div
className="tracklist"
ref={tracklistRef}
data-preview-loc="albums"
onClick={e => {
if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll();
}}
>
<TracklistHeaderRow
visibleCols={visibleCols}
gridStyle={gridStyle}
sortKey={sortKey}
sortDir={sortDir}
onSort={onSort}
allSelected={allSelected}
inSelectMode={inSelectMode}
toggleAll={toggleAll}
startResize={startResize}
t={t}
/>
{/* ── Tracks ── */}
{discNums.map(discNum => (
<div key={discNum}>
{isMultiDisc && (
<div className="disc-header">
<span className="disc-icon">💿</span>
CD {discNum}
{discTitleByNum.get(discNum) && (
<span className="disc-subtitle">{discTitleByNum.get(discNum)}</span>
)}
</div>
)}
{discs.get(discNum)!.map(song => {
const globalIdx = songs.indexOf(song);
return (
<TrackRow
key={song.id}
song={song}
globalIdx={globalIdx}
visibleCols={visibleCols}
gridStyle={gridStyle}
currentTrackId={currentTrackId}
isPlaying={isPlaying}
ratingValue={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
isStarred={starredSongs.has(song.id)}
inSelectMode={inSelectMode}
isContextMenuSong={contextMenuSongId === song.id}
onPlaySong={onPlaySong}
onDoubleClickSong={onDoubleClickSong}
onRate={onRate}
onToggleSongStar={onToggleSongStar}
onContextMenu={onContextMenu}
onToggleSelect={onToggleSelect}
onDragStart={onDragStart}
setContextMenuSongId={setContextMenuSongId}
/>
);
})}
</div>
))}
</div>
</>
);
}