refactor(playlist): G.65 — extract Tracklist + FilterToolbar + displayedSongs (cluster) (#632)

Three-cut cluster on PlaylistDetail.tsx. 1065 → 742 LOC (−323).

PlaylistTracklist — the big one. Owns the bulk-action bar, column
visibility picker, sortable header (3-click cycle: asc → desc →
natural, plus arrow indicator + drag-resize handles between
columns), empty state with "add first song" button, and the song
rows themselves (drag-over indicators, current-track highlight,
bulk-selection check, ctrl/meta/shift selection vs. play vs.
orbit-queue-hint, inline play-next + preview buttons in the title
cell, artist/album links, star/rating cells, format/duration/delete
columns). Subscribes directly to playerStore (currentTrack /
isPlaying / playTrack / openContextMenu / starredOverrides /
userRatingOverrides), previewStore (previewingId / audioStarted),
themeStore (showBitrate), useDragDrop (isDragging),
useOrbitSongRowBehavior — so the parent doesn't have to thread any
of that through props. PL_CENTERED moves to the component because
the only remaining tracklist header lives there now.

PlaylistFilterToolbar — small filter input with clear-X.

playlistDisplayedSongs — pure `getDisplayedSongs(songs, opts)` that
returns the filtered + sorted song list. Exports PlaylistSortKey /
PlaylistSortDir types so PlaylistDetail's sortKey/sortDir state and
PlaylistTracklist's props share the same union types.

PlaylistDetail's import list loses the icons/components that only
the tracklist used (AudioLines, ChevronDown, Check, Heart,
RotateCcw, StarRating, AddToPlaylistSubmenu) — they followed the
component to the new file. Pure code move otherwise.
This commit is contained in:
Frank Stellmacher
2026-05-13 13:09:00 +02:00
committed by GitHub
parent 2e57f54bb2
commit 0b84a199e4
4 changed files with 569 additions and 380 deletions
@@ -0,0 +1,35 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Search, X } from 'lucide-react';
interface Props {
filterText: string;
setFilterText: (v: string) => void;
}
export default function PlaylistFilterToolbar({ filterText, setFilterText }: Props) {
const { t } = useTranslation();
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '8px 16px', flexWrap: 'wrap' }}>
<div style={{ position: 'relative', flex: '1 1 160px', maxWidth: 260 }}>
<Search size={16} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)', pointerEvents: 'none' }} />
<input
className="input-search"
style={{ width: '100%', paddingRight: filterText ? 28 : undefined }}
placeholder={t('albumDetail.filterSongs')}
value={filterText}
onChange={e => setFilterText(e.target.value)}
/>
{filterText && (
<button
style={{ position: 'absolute', right: 6, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 2, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => setFilterText('')}
aria-label="Clear filter"
>
<X size={14} />
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,435 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import {
AudioLines, Check, ChevronDown, ChevronRight, Heart, ListPlus, Play, RotateCcw, Search, Square, Trash2, X,
} from 'lucide-react';
import type { ColDef } from '../../utils/useTracklistColumns';
import type { SubsonicSong } from '../../api/subsonicTypes';
import type { Track } from '../../store/playerStoreTypes';
import { usePlayerStore } from '../../store/playerStore';
import { usePreviewStore } from '../../store/previewStore';
import { useThemeStore } from '../../store/themeStore';
import { useDragDrop } from '../../contexts/DragDropContext';
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
import { songToTrack } from '../../utils/songToTrack';
import { codecLabel, formatDuration } from '../../utils/playlistDetailHelpers';
import type { PlaylistSortKey, PlaylistSortDir } from '../../utils/playlistDisplayedSongs';
import StarRating from '../StarRating';
import { AddToPlaylistSubmenu } from '../ContextMenu';
const PL_CENTERED = new Set(['favorite', 'rating', 'duration']);
interface Props {
// Column config / picker
allColumns: readonly ColDef[];
visibleCols: ColDef[];
gridStyle: React.CSSProperties;
colVisible: Set<string>;
toggleColumn: (key: string) => void;
resetColumns: () => void;
pickerOpen: boolean;
setPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
pickerRef: React.RefObject<HTMLDivElement | null>;
startResize: (e: React.MouseEvent, colIndex: number, direction?: 1 | -1) => void;
tracklistRef: React.RefObject<HTMLDivElement | null>;
// Data
songs: SubsonicSong[];
displayedSongs: SubsonicSong[];
displayedTracks: Track[];
isFiltered: boolean;
id: string | undefined;
// Sort
sortKey: PlaylistSortKey;
setSortKey: React.Dispatch<React.SetStateAction<PlaylistSortKey>>;
sortDir: PlaylistSortDir;
setSortDir: React.Dispatch<React.SetStateAction<PlaylistSortDir>>;
sortClickCount: number;
setSortClickCount: React.Dispatch<React.SetStateAction<number>>;
// Selection
selectedIds: Set<string>;
setSelectedIds: React.Dispatch<React.SetStateAction<Set<string>>>;
allSelected: boolean;
toggleAll: () => void;
toggleSelect: (id: string, idx: number, shift: boolean) => void;
showBulkPlPicker: boolean;
setShowBulkPlPicker: React.Dispatch<React.SetStateAction<boolean>>;
bulkRemove: () => void;
// Context menu + DnD visual
contextMenuSongId: string | null;
setContextMenuSongId: React.Dispatch<React.SetStateAction<string | null>>;
dropTargetIdx: { idx: number; before: boolean } | null;
// Rating / star / row mouse / delete
ratings: Record<string, number>;
starredSongs: Set<string>;
handleRate: (songId: string, rating: number) => void;
handleToggleStar: (song: SubsonicSong, e: React.MouseEvent) => void;
handleRowMouseDown: (e: React.MouseEvent, idx: number) => void;
handleRowMouseEnter: (idx: number, e: React.MouseEvent) => void;
removeSong: (idx: number) => void;
// Empty state
setSearchOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
export default function PlaylistTracklist({
allColumns, visibleCols, gridStyle, colVisible, toggleColumn, resetColumns,
pickerOpen, setPickerOpen, pickerRef, startResize, tracklistRef,
songs, displayedSongs, displayedTracks, isFiltered, id,
sortKey, setSortKey, sortDir, setSortDir, sortClickCount, setSortClickCount,
selectedIds, setSelectedIds, allSelected, toggleAll, toggleSelect,
showBulkPlPicker, setShowBulkPlPicker, bulkRemove,
contextMenuSongId, setContextMenuSongId, dropTargetIdx,
ratings, starredSongs, handleRate, handleToggleStar,
handleRowMouseDown, handleRowMouseEnter, removeSong,
setSearchOpen,
}: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const playTrack = usePlayerStore(s => s.playTrack);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const previewingId = usePreviewStore(s => s.previewingId);
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
const showBitrate = useThemeStore(s => s.showBitrate);
const { isDragging } = useDragDrop();
const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior();
return (
<div className="tracklist" data-preview-loc="playlists" ref={tracklistRef}>
{/* Bulk action bar */}
{selectedIds.size > 0 && (
<div className="bulk-action-bar">
<span className="bulk-action-count">
{t('common.bulkSelected', { count: selectedIds.size })}
</span>
<div className="bulk-pl-picker-wrap">
<button
className="btn btn-surface btn-sm"
onClick={() => setShowBulkPlPicker(v => !v)}
>
<ListPlus size={14} />
{t('common.bulkAddToPlaylist')}
</button>
{showBulkPlPicker && (
<AddToPlaylistSubmenu
songIds={[...selectedIds]}
onDone={() => { setShowBulkPlPicker(false); setSelectedIds(new Set()); }}
dropDown
/>
)}
</div>
<button
className="btn btn-surface btn-sm"
style={{ color: 'var(--danger)' }}
onClick={bulkRemove}
>
<Trash2 size={14} />
{t('common.bulkRemoveFromPlaylist')}
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setSelectedIds(new Set())}
>
<X size={13} />
{t('common.bulkClear')}
</button>
</div>
)}
{/* Column visibility picker */}
<div className="tracklist-col-picker-wrapper" ref={pickerRef}>
<div className="tracklist-col-picker">
<button
className="tracklist-col-picker-btn"
onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }}
data-tooltip={t('albumDetail.columns')}
>
<ChevronDown size={14} />
</button>
{pickerOpen && (
<div className="tracklist-col-picker-menu">
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
{allColumns.filter(c => !c.required).map(c => {
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key;
const isOn = colVisible.has(c.key);
return (
<button
key={c.key}
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
onClick={() => toggleColumn(c.key)}
>
<span className="tracklist-col-picker-check">{isOn && <Check size={13} />}</span>
{label}
</button>
);
})}
<div className="tracklist-col-picker-divider" />
<button className="tracklist-col-picker-reset" onClick={resetColumns}>
<RotateCcw size={13} />
{t('albumDetail.resetColumns')}
</button>
</div>
)}
</div>
</div>
{/* Header */}
<div style={{ position: 'relative' }}>
<div className="tracklist-header tracklist-va" style={gridStyle}>
{visibleCols.map((colDef, colIndex) => {
const key = colDef.key;
const isLastCol = colIndex === visibleCols.length - 1;
const isCentered = PL_CENTERED.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
const sortableCols = new Set(['title', 'artist', 'favorite', 'rating', 'duration', 'album']);
const canSort = sortableCols.has(key);
const isSortActive = canSort && sortKey === key;
const handleSortClick = () => {
if (!canSort) return;
if (sortKey === key) {
const nextCount = sortClickCount + 1;
if (nextCount >= 3) {
setSortKey('natural');
setSortDir('asc');
setSortClickCount(0);
} else {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
setSortClickCount(nextCount);
}
} else {
setSortKey(key as PlaylistSortKey);
setSortDir('asc');
setSortClickCount(1);
}
};
const renderSortIndicator = () => {
if (!isSortActive) return null;
return (
<span style={{ marginLeft: 4, fontSize: 10, opacity: 0.7 }}>
{sortDir === 'asc' ? '▲' : '▼'}
</span>
);
};
if (key === 'num') return (
<div key="num" className="track-num">
<span
className={`bulk-check${allSelected ? ' checked' : ''}${selectedIds.size > 0 ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleAll(); }}
style={{ cursor: 'pointer' }}
/>
<span className="track-num-number">#</span>
</div>
);
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
return (
<div
key="title"
onClick={handleSortClick}
style={{
position: 'relative',
padding: 0,
margin: 0,
minWidth: 0,
overflow: 'hidden',
cursor: canSort ? 'pointer' : 'default',
userSelect: 'none',
}}
className={isSortActive ? 'tracklist-header-cell-active' : ''}
>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isSortActive ? 600 : 400 }}>{label}</span>
{canSort && renderSortIndicator()}
</div>
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
</div>
);
}
if (key === 'delete') return <div key="delete" />;
return (
<div
key={key}
onClick={handleSortClick}
style={{
position: 'relative',
padding: 0,
margin: 0,
minWidth: 0,
overflow: 'hidden',
cursor: canSort ? 'pointer' : 'default',
userSelect: 'none',
}}
className={isSortActive ? 'tracklist-header-cell-active' : ''}
>
<div
style={{
display: 'flex',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: isCentered ? 'center' : 'flex-start',
paddingLeft: isCentered ? 0 : 12,
}}
>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: isSortActive ? 600 : 400 }}>{label}</span>
{canSort && renderSortIndicator()}
</div>
{!isLastCol && key !== 'delete' && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
)}
</div>
);
})}
</div>
</div>
{songs.length === 0 && (
<div className="empty-state" style={{ padding: '2rem 0', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
<span>{t('playlists.emptyPlaylist')}</span>
<button className="btn btn-primary" onClick={() => setSearchOpen(true)}>
<Search size={15} />
{t('playlists.addFirstSong')}
</button>
</div>
)}
{displayedSongs.map((song, i) => {
const realIdx = isFiltered ? songs.indexOf(song) : i;
return (
<React.Fragment key={song.id + i}>
{!isFiltered && isDragging && dropTargetIdx?.idx === i && dropTargetIdx.before && (
<div className="playlist-drop-indicator" />
)}
<div
data-track-idx={realIdx}
className={`track-row track-row-va track-row-with-actions tracklist-playlist${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
style={gridStyle}
onMouseEnter={e => !isFiltered && handleRowMouseEnter(i, e)}
onMouseDown={e => handleRowMouseDown(e, realIdx)}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (e.ctrlKey || e.metaKey) {
toggleSelect(song.id, i, false);
} else if (selectedIds.size > 0) {
toggleSelect(song.id, i, e.shiftKey);
} else if (orbitActive) {
queueHint();
} else {
playTrack(displayedTracks[i], displayedTracks);
}
}}
onDoubleClick={orbitActive ? e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (e.ctrlKey || e.metaKey || selectedIds.size > 0) return;
addTrackToOrbit(song.id);
} : undefined}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song', undefined, id, realIdx);
}}
>
{visibleCols.map(colDef => {
const inSelectMode = selectedIds.size > 0;
switch (colDef.key) {
case 'num': return (
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`}>
<span className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`} onClick={e => { e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} />
{currentTrack?.id === song.id && isPlaying ? (
<span className="track-num-eq"><AudioLines className="eq-bars" size={14} /></span>
) : (
<span className="track-num-number">{i + 1}</span>
)}
</div>
);
case 'title': return (
<div key="title" className="track-info track-info-suggestion">
<button
type="button"
className="playlist-suggestion-play-btn"
onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(displayedTracks[i], displayedTracks); }}
data-tooltip={t('common.play')}
aria-label={t('common.play')}
>
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
</button>
<button
type="button"
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
onClick={e => {
e.stopPropagation();
usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'playlists');
}}
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
>
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
</svg>
{previewingId === song.id
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
</button>
<span className="track-title">{song.title}</span>
</div>
);
case 'artist': return (
<div key="artist" className="track-artist-cell">
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist}</span>
</div>
);
case 'album': return (
<div key="album" className="track-artist-cell">
<span className={`track-artist${song.albumId ? ' track-artist-link' : ''}`} style={{ cursor: song.albumId ? 'pointer' : 'default' }} onClick={e => { if (song.albumId) { e.stopPropagation(); navigate(`/album/${song.albumId}`); } }}>{song.album}</span>
</div>
);
case 'favorite': return (
<div key="favorite" className="track-star-cell">
<button className="btn btn-ghost track-star-btn" onClick={e => handleToggleStar(song, e)} style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}>
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
</button>
</div>
);
case 'rating': return <StarRating key="rating" value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />;
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
case 'format': return (
<div key="format" className="track-meta">
{(song.suffix || (showBitrate && song.bitRate)) && <span className="track-codec">{codecLabel(song, showBitrate)}</span>}
</div>
);
case 'delete': return (
<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">
<Trash2 size={13} />
</button>
</div>
);
default: return null;
}
})}
</div>
{!isFiltered && isDragging && dropTargetIdx?.idx === i && !dropTargetIdx.before && (
<div className="playlist-drop-indicator" />
)}
</React.Fragment>
);
})}
</div>
);
}
+56 -380
View File
@@ -44,6 +44,9 @@ import CsvImportReportModal from '../components/playlist/CsvImportReportModal';
import PlaylistSongSearchPanel from '../components/playlist/PlaylistSongSearchPanel';
import PlaylistSuggestions from '../components/playlist/PlaylistSuggestions';
import PlaylistHero from '../components/playlist/PlaylistHero';
import PlaylistTracklist from '../components/playlist/PlaylistTracklist';
import PlaylistFilterToolbar from '../components/playlist/PlaylistFilterToolbar';
import { getDisplayedSongs, type PlaylistSortKey, type PlaylistSortDir } from '../utils/playlistDisplayedSongs';
// ── Column configuration ──────────────────────────────────────────────────────
const PL_COLUMNS: readonly ColDef[] = [
@@ -58,8 +61,6 @@ const PL_COLUMNS: readonly ColDef[] = [
{ key: 'delete', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
];
const PL_CENTERED = new Set(['favorite', 'rating', 'duration']);
export default function PlaylistDetail() {
const { id } = useParams<{ id: string }>();
const { t } = useTranslation();
@@ -113,8 +114,8 @@ export default function PlaylistDetail() {
const [editingMeta, setEditingMeta] = useState(false);
const [customCoverId, setCustomCoverId] = useState<string | null>(null);
const [filterText, setFilterText] = useState('');
const [sortKey, setSortKey] = useState<'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration'>('natural');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [sortKey, setSortKey] = useState<PlaylistSortKey>('natural');
const [sortDir, setSortDir] = useState<PlaylistSortDir>('asc');
const [sortClickCount, setSortClickCount] = useState(0);
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
@@ -536,34 +537,13 @@ export default function PlaylistDetail() {
const existingIds = useMemo(() => new Set(songs.map(s => s.id)), [songs]);
const tracks = useMemo(() => songs.map(songToTrack), [songs]);
const displayedSongs = useMemo(() => {
const q = filterText.trim().toLowerCase();
if (!q && sortKey === 'natural') return songs;
let result = [...songs];
if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q));
if (sortKey !== 'natural') {
result.sort((a, b) => {
let av: string | number;
let bv: string | number;
const effectiveRating = (s: SubsonicSong) => ratings[s.id] ?? userRatingOverrides[s.id] ?? s.userRating ?? 0;
const effectiveStarred = (s: SubsonicSong) => (s.id in starredOverrides ? starredOverrides[s.id] : starredSongs.has(s.id)) ? 1 : 0;
switch (sortKey) {
case 'title': av = a.title; bv = b.title; break;
case 'artist': av = a.artist ?? ''; bv = b.artist ?? ''; break;
case 'album': av = a.album ?? ''; bv = b.album ?? ''; break;
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;
default: av = a.title; bv = b.title;
}
if (typeof av === 'number' && typeof bv === 'number') {
return sortDir === 'asc' ? av - bv : bv - av;
}
return sortDir === 'asc' ? (av as string).localeCompare(bv as string) : (bv as string).localeCompare(av as string);
});
}
return result;
}, [songs, filterText, sortKey, sortDir, ratings, userRatingOverrides, starredOverrides, starredSongs]);
const displayedSongs = useMemo(
() => getDisplayedSongs(songs, {
filterText, sortKey, sortDir,
ratings, userRatingOverrides, starredOverrides, starredSongs,
}),
[songs, filterText, sortKey, sortDir, ratings, userRatingOverrides, starredOverrides, starredSongs],
);
const displayedTracks = useMemo(
() => displayedSongs === songs ? tracks : displayedSongs.map(songToTrack),
[displayedSongs, songs, tracks],
@@ -671,357 +651,53 @@ export default function PlaylistDetail() {
{/* ── Filter / sort toolbar ── */}
{songs.length > 0 && (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '8px 16px', flexWrap: 'wrap' }}>
<div style={{ position: 'relative', flex: '1 1 160px', maxWidth: 260 }}>
<Search size={16} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)', pointerEvents: 'none' }} />
<input
className="input-search"
style={{ width: '100%', paddingRight: filterText ? 28 : undefined }}
placeholder={t('albumDetail.filterSongs')}
value={filterText}
onChange={e => setFilterText(e.target.value)}
/>
{filterText && (
<button
style={{ position: 'absolute', right: 6, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 2, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => setFilterText('')}
aria-label="Clear filter"
>
<X size={14} />
</button>
)}
</div>
</div>
<PlaylistFilterToolbar filterText={filterText} setFilterText={setFilterText} />
)}
{/* ── Tracklist ── */}
<div className="tracklist" data-preview-loc="playlists" ref={tracklistRef}>
{/* Bulk action bar */}
{selectedIds.size > 0 && (
<div className="bulk-action-bar">
<span className="bulk-action-count">
{t('common.bulkSelected', { count: selectedIds.size })}
</span>
<div className="bulk-pl-picker-wrap">
<button
className="btn btn-surface btn-sm"
onClick={() => setShowBulkPlPicker(v => !v)}
>
<ListPlus size={14} />
{t('common.bulkAddToPlaylist')}
</button>
{showBulkPlPicker && (
<AddToPlaylistSubmenu
songIds={[...selectedIds]}
onDone={() => { setShowBulkPlPicker(false); setSelectedIds(new Set()); }}
dropDown
/>
)}
</div>
<button
className="btn btn-surface btn-sm"
style={{ color: 'var(--danger)' }}
onClick={bulkRemove}
>
<Trash2 size={14} />
{t('common.bulkRemoveFromPlaylist')}
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setSelectedIds(new Set())}
>
<X size={13} />
{t('common.bulkClear')}
</button>
</div>
)}
{/* Column visibility picker */}
<div className="tracklist-col-picker-wrapper" ref={pickerRef}>
<div className="tracklist-col-picker">
<button
className="tracklist-col-picker-btn"
onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }}
data-tooltip={t('albumDetail.columns')}
>
<ChevronDown size={14} />
</button>
{pickerOpen && (
<div className="tracklist-col-picker-menu">
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
{PL_COLUMNS.filter(c => !c.required).map(c => {
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key;
const isOn = colVisible.has(c.key);
return (
<button
key={c.key}
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
onClick={() => toggleColumn(c.key)}
>
<span className="tracklist-col-picker-check">{isOn && <Check size={13} />}</span>
{label}
</button>
);
})}
<div className="tracklist-col-picker-divider" />
<button className="tracklist-col-picker-reset" onClick={resetColumns}>
<RotateCcw size={13} />
{t('albumDetail.resetColumns')}
</button>
</div>
)}
</div>
</div>
{/* Header */}
<div style={{ position: 'relative' }}>
<div className="tracklist-header tracklist-va" style={gridStyle}>
{visibleCols.map((colDef, colIndex) => {
const key = colDef.key;
const isLastCol = colIndex === visibleCols.length - 1;
const isCentered = PL_CENTERED.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
const sortableCols = new Set(['title', 'artist', 'favorite', 'rating', 'duration', 'album']);
const canSort = sortableCols.has(key);
const isSortActive = canSort && sortKey === key;
const handleSortClick = () => {
if (!canSort) return;
if (sortKey === key) {
const nextCount = sortClickCount + 1;
if (nextCount >= 3) {
setSortKey('natural');
setSortDir('asc');
setSortClickCount(0);
} else {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
setSortClickCount(nextCount);
}
} else {
setSortKey(key as typeof sortKey);
setSortDir('asc');
setSortClickCount(1);
}
};
const renderSortIndicator = () => {
if (!isSortActive) return null;
return (
<span style={{ marginLeft: 4, fontSize: 10, opacity: 0.7 }}>
{sortDir === 'asc' ? '▲' : '▼'}
</span>
);
};
if (key === 'num') return (
<div key="num" className="track-num">
<span
className={`bulk-check${allSelected ? ' checked' : ''}${selectedIds.size > 0 ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleAll(); }}
style={{ cursor: 'pointer' }}
/>
<span className="track-num-number">#</span>
</div>
);
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
return (
<div
key="title"
onClick={handleSortClick}
style={{
position: 'relative',
padding: 0,
margin: 0,
minWidth: 0,
overflow: 'hidden',
cursor: canSort ? 'pointer' : 'default',
userSelect: 'none',
}}
className={isSortActive ? 'tracklist-header-cell-active' : ''}
>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isSortActive ? 600 : 400 }}>{label}</span>
{canSort && renderSortIndicator()}
</div>
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
</div>
);
}
if (key === 'delete') return <div key="delete" />;
return (
<div
key={key}
onClick={handleSortClick}
style={{
position: 'relative',
padding: 0,
margin: 0,
minWidth: 0,
overflow: 'hidden',
cursor: canSort ? 'pointer' : 'default',
userSelect: 'none',
}}
className={isSortActive ? 'tracklist-header-cell-active' : ''}
>
<div
style={{
display: 'flex',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: isCentered ? 'center' : 'flex-start',
paddingLeft: isCentered ? 0 : 12,
}}
>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: isSortActive ? 600 : 400 }}>{label}</span>
{canSort && renderSortIndicator()}
</div>
{!isLastCol && key !== 'delete' && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
)}
</div>
);
})}
</div>
</div>
{songs.length === 0 && (
<div className="empty-state" style={{ padding: '2rem 0', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
<span>{t('playlists.emptyPlaylist')}</span>
<button className="btn btn-primary" onClick={() => setSearchOpen(true)}>
<Search size={15} />
{t('playlists.addFirstSong')}
</button>
</div>
)}
{displayedSongs.map((song, i) => {
const realIdx = isFiltered ? songs.indexOf(song) : i;
return (
<React.Fragment key={song.id + i}>
{!isFiltered && isDragging && dropTargetIdx?.idx === i && dropTargetIdx.before && (
<div className="playlist-drop-indicator" />
)}
<div
data-track-idx={realIdx}
className={`track-row track-row-va track-row-with-actions tracklist-playlist${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
style={gridStyle}
onMouseEnter={e => !isFiltered && handleRowMouseEnter(i, e)}
onMouseDown={e => handleRowMouseDown(e, realIdx)}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (e.ctrlKey || e.metaKey) {
toggleSelect(song.id, i, false);
} else if (selectedIds.size > 0) {
toggleSelect(song.id, i, e.shiftKey);
} else if (orbitActive) {
queueHint();
} else {
playTrack(displayedTracks[i], displayedTracks);
}
}}
onDoubleClick={orbitActive ? e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (e.ctrlKey || e.metaKey || selectedIds.size > 0) return;
addTrackToOrbit(song.id);
} : undefined}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song', undefined, id, realIdx);
}}
>
{visibleCols.map(colDef => {
const inSelectMode = selectedIds.size > 0;
switch (colDef.key) {
case 'num': return (
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`}>
<span className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`} onClick={e => { e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} />
{currentTrack?.id === song.id && isPlaying ? (
<span className="track-num-eq"><AudioLines className="eq-bars" size={14} /></span>
) : (
<span className="track-num-number">{i + 1}</span>
)}
</div>
);
case 'title': return (
<div key="title" className="track-info track-info-suggestion">
<button
type="button"
className="playlist-suggestion-play-btn"
onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(displayedTracks[i], displayedTracks); }}
data-tooltip={t('common.play')}
aria-label={t('common.play')}
>
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
</button>
<button
type="button"
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
onClick={e => {
e.stopPropagation();
usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'playlists');
}}
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
>
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
</svg>
{previewingId === song.id
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
</button>
<span className="track-title">{song.title}</span>
</div>
);
case 'artist': return (
<div key="artist" className="track-artist-cell">
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist}</span>
</div>
);
case 'album': return (
<div key="album" className="track-artist-cell">
<span className={`track-artist${song.albumId ? ' track-artist-link' : ''}`} style={{ cursor: song.albumId ? 'pointer' : 'default' }} onClick={e => { if (song.albumId) { e.stopPropagation(); navigate(`/album/${song.albumId}`); } }}>{song.album}</span>
</div>
);
case 'favorite': return (
<div key="favorite" className="track-star-cell">
<button className="btn btn-ghost track-star-btn" onClick={e => handleToggleStar(song, e)} style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}>
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
</button>
</div>
);
case 'rating': return <StarRating key="rating" value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />;
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
case 'format': return (
<div key="format" className="track-meta">
{(song.suffix || (showBitrate && song.bitRate)) && <span className="track-codec">{codecLabel(song, showBitrate)}</span>}
</div>
);
case 'delete': return (
<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">
<Trash2 size={13} />
</button>
</div>
);
default: return null;
}
})}
</div>
{!isFiltered && isDragging && dropTargetIdx?.idx === i && !dropTargetIdx.before && (
<div className="playlist-drop-indicator" />
)}
</React.Fragment>
);
})}
</div>
<PlaylistTracklist
allColumns={PL_COLUMNS}
visibleCols={visibleCols}
gridStyle={gridStyle}
colVisible={colVisible}
toggleColumn={toggleColumn}
resetColumns={resetColumns}
pickerOpen={pickerOpen}
setPickerOpen={setPickerOpen}
pickerRef={pickerRef}
startResize={startResize}
tracklistRef={tracklistRef}
songs={songs}
displayedSongs={displayedSongs}
displayedTracks={displayedTracks}
isFiltered={isFiltered}
id={id}
sortKey={sortKey}
setSortKey={setSortKey}
sortDir={sortDir}
setSortDir={setSortDir}
sortClickCount={sortClickCount}
setSortClickCount={setSortClickCount}
selectedIds={selectedIds}
setSelectedIds={setSelectedIds}
allSelected={allSelected}
toggleAll={toggleAll}
toggleSelect={toggleSelect}
showBulkPlPicker={showBulkPlPicker}
setShowBulkPlPicker={setShowBulkPlPicker}
bulkRemove={bulkRemove}
contextMenuSongId={contextMenuSongId}
setContextMenuSongId={setContextMenuSongId}
dropTargetIdx={dropTargetIdx}
ratings={ratings}
starredSongs={starredSongs}
handleRate={handleRate}
handleToggleStar={handleToggleStar}
handleRowMouseDown={handleRowMouseDown}
handleRowMouseEnter={handleRowMouseEnter}
removeSong={removeSong}
setSearchOpen={setSearchOpen}
/>
{/* ── Suggestions ── */}
<PlaylistSuggestions
+43
View File
@@ -0,0 +1,43 @@
import type { SubsonicSong } from '../api/subsonicTypes';
export type PlaylistSortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration';
export type PlaylistSortDir = 'asc' | 'desc';
export interface DisplayedSongsOptions {
filterText: string;
sortKey: PlaylistSortKey;
sortDir: PlaylistSortDir;
ratings: Record<string, number>;
userRatingOverrides: Record<string, number>;
starredOverrides: Record<string, boolean>;
starredSongs: Set<string>;
}
export function getDisplayedSongs(songs: SubsonicSong[], opts: DisplayedSongsOptions): SubsonicSong[] {
const q = opts.filterText.trim().toLowerCase();
if (!q && opts.sortKey === 'natural') return songs;
let result = [...songs];
if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q));
if (opts.sortKey !== 'natural') {
result.sort((a, b) => {
let av: string | number;
let bv: string | number;
const effectiveRating = (s: SubsonicSong) => opts.ratings[s.id] ?? opts.userRatingOverrides[s.id] ?? s.userRating ?? 0;
const effectiveStarred = (s: SubsonicSong) => (s.id in opts.starredOverrides ? opts.starredOverrides[s.id] : opts.starredSongs.has(s.id)) ? 1 : 0;
switch (opts.sortKey) {
case 'title': av = a.title; bv = b.title; break;
case 'artist': av = a.artist ?? ''; bv = b.artist ?? ''; break;
case 'album': av = a.album ?? ''; bv = b.album ?? ''; break;
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;
default: av = a.title; bv = b.title;
}
if (typeof av === 'number' && typeof bv === 'number') {
return opts.sortDir === 'asc' ? av - bv : bv - av;
}
return opts.sortDir === 'asc' ? (av as string).localeCompare(bv as string) : (bv as string).localeCompare(av as string);
});
}
return result;
}