feat(tracklist): multi-select + psyDnD, filter/sort, settings & UI polish

- AlbumTrackList: extract TrackRow as React.memo, selection state moved to
  selectionStore (Zustand) for O(1) re-renders per toggle; Ctrl/Cmd+Click
  enters select mode; drag selected tracks as {type:'songs'} payload;
  selection clears on outside click or song-list change
- QueuePanel: handle 'songs' multi-track drop type; whitelist drag types to
  suppress drop feedback for non-queue drags (lyrics grip etc.)
- AlbumDetail + PlaylistDetail: filter/sort toolbar (title/artist, natural
  order); disc grouping bypassed when sorted; playlist reorder DnD disabled
  while filter active
- useTracklistColumns: 'known' field auto-shows newly added columns for
  existing users
- PlayerBar: mute/unmute restores previous volume via premuteVolumeRef
  instead of hardcoded 0.7
- Settings/Input: reset buttons restyled as RotateCcw icon above card,
  matching HomeCustomizer layout
- i18n: filterSongs, sortNatural, sortByTitle, sortByArtist keys across
  all 7 locales
- components.css: album-card-title/artist nowrap to keep playlist grid
  cards uniform height

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-11 11:42:31 +02:00
parent b46acc88e3
commit b9b8f3fc15
16 changed files with 573 additions and 243 deletions
+310 -185
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Play, Heart, ListPlus, X, ChevronDown, Check } from 'lucide-react'; import { Play, Heart, ListPlus, X, ChevronDown, Check } from 'lucide-react';
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns'; import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
import { SubsonicSong } from '../api/subsonic'; import { SubsonicSong } from '../api/subsonic';
@@ -9,6 +9,7 @@ import { useDragDrop } from '../contexts/DragDropContext';
import { AddToPlaylistSubmenu } from './ContextMenu'; import { AddToPlaylistSubmenu } from './ContextMenu';
import { useIsMobile } from '../hooks/useIsMobile'; import { useIsMobile } from '../hooks/useIsMobile';
import StarRating from './StarRating'; import StarRating from './StarRating';
import { useSelectionStore } from '../store/selectionStore';
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
@@ -26,10 +27,6 @@ function codecLabel(song: { suffix?: string; bitRate?: number }): string {
} }
// ── Column configuration ────────────────────────────────────────────────────── // ── Column configuration ──────────────────────────────────────────────────────
// 'num' → always 60 px fixed, no resize handle
// 'title' → minmax(150px, 1fr) via flex:true, absorbs window-resize changes
// rest → persistent px values from useTracklistColumns hook
const COLUMNS: readonly ColDef[] = [ 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 },
@@ -43,18 +40,17 @@ const COLUMNS: readonly ColDef[] = [
type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre'; type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre';
// Columns where header label is centred in the cell (matches row controls below)
const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration']); const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration']);
// ── Props ───────────────────────────────────────────────────────────────────── // ── Props ─────────────────────────────────────────────────────────────────────
interface AlbumTrackListProps { interface AlbumTrackListProps {
songs: SubsonicSong[]; songs: SubsonicSong[];
sorted?: boolean;
hasVariousArtists: boolean; hasVariousArtists: boolean;
currentTrack: Track | null; currentTrack: Track | null;
isPlaying: boolean; isPlaying: boolean;
ratings: Record<string, number>; ratings: Record<string, number>;
/** Merged after local `ratings` (e.g. skip→1★ optimistic updates). */
userRatingOverrides: Record<string, number>; userRatingOverrides: Record<string, number>;
starredSongs: Set<string>; starredSongs: Set<string>;
onPlaySong: (song: SubsonicSong) => void; onPlaySong: (song: SubsonicSong) => void;
@@ -63,156 +59,70 @@ interface AlbumTrackListProps {
onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => void; onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => void;
} }
export default function AlbumTrackList({ // ── TrackRow (memoised) ───────────────────────────────────────────────────────
songs, // Subscribes only to its own boolean in the selection store → O(1) re-render on toggle.
hasVariousArtists,
currentTrack, interface TrackRowProps {
song: SubsonicSong;
globalIdx: number;
visibleCols: readonly ColDef[];
gridStyle: React.CSSProperties;
currentTrackId: string | null;
isPlaying: boolean;
ratingValue: number;
isStarred: boolean;
inSelectMode: boolean;
isContextMenuSong: boolean;
onPlaySong: (song: SubsonicSong) => void;
onRate: (songId: string, rating: number) => void;
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
onContextMenu: AlbumTrackListProps['onContextMenu'];
onToggleSelect: (id: string, globalIdx: number, shift: boolean) => void;
onDragStart: (song: SubsonicSong, me: MouseEvent) => void;
setContextMenuSongId: (id: string | null) => void;
}
const TrackRow = React.memo(function TrackRow({
song,
globalIdx,
visibleCols,
gridStyle,
currentTrackId,
isPlaying, isPlaying,
ratings, ratingValue,
userRatingOverrides, isStarred,
starredSongs, inSelectMode,
isContextMenuSong,
onPlaySong, onPlaySong,
onRate, onRate,
onToggleSongStar, onToggleSongStar,
onContextMenu, onContextMenu,
}: AlbumTrackListProps) { onToggleSelect,
onDragStart,
setContextMenuSongId,
}: TrackRowProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const isMobile = useIsMobile(); // Fine-grained: only re-renders when THIS row's selection boolean flips.
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null); const isSelected = useSelectionStore(s => s.selectedIds.has(song.id));
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); const isActive = currentTrackId === song.id;
const psyDrag = useDragDrop();
// ── Bulk select ─────────────────────────────────────────────────────────── const renderCell = (colDef: ColDef) => {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
const [showPlPicker, setShowPlPicker] = useState(false);
// ── Column state (resize, visibility, picker) via shared hook ────────────
const {
colWidths, colVisible, visibleCols, gridStyle,
startResize, toggleColumn,
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns');
const toggleSelect = (id: string, globalIdx: number, shift: boolean) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (shift && lastSelectedIdx !== null) {
const from = Math.min(lastSelectedIdx, globalIdx);
const to = Math.max(lastSelectedIdx, globalIdx);
songs.slice(from, to + 1).forEach(s => next.add(s.id));
} else {
next.has(id) ? next.delete(id) : next.add(id);
}
return next;
});
setLastSelectedIdx(globalIdx);
};
const allSelected = selectedIds.size === songs.length && songs.length > 0;
const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id)));
useEffect(() => {
if (!contextMenuOpen) setContextMenuSongId(null);
}, [contextMenuOpen]);
useEffect(() => {
if (!showPlPicker) return;
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.bulk-pl-picker-wrap')) setShowPlPicker(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showPlPicker]);
const discs = new Map<number, SubsonicSong[]>();
songs.forEach(song => {
const disc = song.discNumber ?? 1;
if (!discs.has(disc)) discs.set(disc, []);
discs.get(disc)!.push(song);
});
const discNums = Array.from(discs.keys()).sort((a, b) => a - b);
const isMultiDisc = discNums.length > 1;
const inSelectMode = selectedIds.size > 0;
// ── Header cell renderer ──────────────────────────────────────────────────
const renderHeaderCell = (colDef: ColDef, colIndex: number) => {
const key = colDef.key as ColKey; const key = colDef.key as ColKey;
const isLastCol = colIndex === visibleCols.length - 1;
const isCentered = CENTERED_COLS.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
// num header: checkbox + # label, mirrors row-cell layout exactly
if (key === 'num') {
return (
<div key={key} className="track-num">
<span
className={`bulk-check${allSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleAll(); }}
style={{ cursor: 'pointer' }}
/>
<span className="track-num-number">#</span>
</div>
);
}
// title (1fr): label + divider on RIGHT edge that controls the NEXT px column (drag→shrinks it)
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</div>
{hasNextCol && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
)}
</div>
);
}
// px-width columns: centred (compact controls) or left-aligned label + right-edge divider
const isResizable = !isLastCol;
return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<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' }}>{label}</span>
</div>
{isResizable && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
)}
</div>
);
};
// ── Row cell renderer ─────────────────────────────────────────────────────
const renderRowCell = (key: ColKey, song: SubsonicSong, globalIdx: number) => {
switch (key) { switch (key) {
case 'num': case 'num':
return ( return (
<div <div
key="num" key="num"
className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`} className={`track-num${isActive ? ' track-num-active' : ''}${isActive && !isPlaying ? ' track-num-paused' : ''}`}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={e => { e.stopPropagation(); onPlaySong(song); }} onClick={e => { e.stopPropagation(); onPlaySong(song); }}
> >
<span <span
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`} className={`bulk-check${isSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }} onClick={e => { e.stopPropagation(); onToggleSelect(song.id, globalIdx, e.shiftKey); }}
/> />
{currentTrack?.id === song.id && isPlaying && ( {isActive && isPlaying && (
<span className="track-num-eq"> <span className="track-num-eq">
<div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div> <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
</span> </span>
@@ -252,11 +162,11 @@ export default function AlbumTrackList({
return ( return (
<div key="favorite" className="track-star-cell"> <div key="favorite" className="track-star-cell">
<button <button
className={`btn btn-ghost track-star-btn${starredSongs.has(song.id) ? ' is-starred' : ''}`} className={`btn btn-ghost track-star-btn${isStarred ? ' is-starred' : ''}`}
onClick={e => onToggleSongStar(song, e)} onClick={e => onToggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')} data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
> >
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} /> <Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
</button> </button>
</div> </div>
); );
@@ -264,7 +174,7 @@ export default function AlbumTrackList({
return ( return (
<StarRating <StarRating
key="rating" key="rating"
value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0} value={ratingValue}
onChange={r => onRate(song.id, r)} onChange={r => onRate(song.id, r)}
/> />
); );
@@ -293,7 +203,232 @@ export default function AlbumTrackList({
} }
}; };
// ── Mobile tracklist ───────────────────────────────────────────────────── return (
<div
className={`track-row track-row-va${isActive ? ' active' : ''}${isContextMenuSong ? ' context-active' : ''}${isSelected ? ' bulk-selected' : ''}`}
style={gridStyle}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (e.ctrlKey || e.metaKey) {
onToggleSelect(song.id, globalIdx, false);
} else if (inSelectMode) {
onToggleSelect(song.id, globalIdx, e.shiftKey);
} else {
onPlaySong(song);
}
}}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
role="row"
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
onDragStart(song, me);
}
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
{visibleCols.map(colDef => renderCell(colDef))}
</div>
);
});
// ── AlbumTrackList ────────────────────────────────────────────────────────────
export default function AlbumTrackList({
songs,
sorted,
hasVariousArtists: _hasVariousArtists,
currentTrack,
isPlaying,
ratings,
userRatingOverrides,
starredSongs,
onPlaySong,
onRate,
onToggleSongStar,
onContextMenu,
}: AlbumTrackListProps) {
const { t } = useTranslation();
const isMobile = useIsMobile();
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const psyDrag = useDragDrop();
// Selection state lives in selectionStore — only the toggled row re-renders (O(1)).
const selectedCount = useSelectionStore(s => s.selectedIds.size);
const inSelectMode = selectedCount > 0;
const allSelected = selectedCount === songs.length && songs.length > 0;
const lastSelectedIdxRef = useRef<number | null>(null);
const [showPlPicker, setShowPlPicker] = useState(false);
// ── Column state ──────────────────────────────────────────────────────────
const {
colVisible, visibleCols, gridStyle,
startResize, toggleColumn,
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns');
// Clear selection when the song list changes (different album / filter applied).
useEffect(() => {
useSelectionStore.getState().clearAll();
lastSelectedIdxRef.current = null;
}, [songs]);
useEffect(() => {
if (!contextMenuOpen) setContextMenuSongId(null);
}, [contextMenuOpen]);
// Clear selection on click outside the tracklist (header, album art, etc.)
useEffect(() => {
if (!inSelectMode) return;
const handler = (e: MouseEvent) => {
if (tracklistRef.current && !tracklistRef.current.contains(e.target as Node)) {
useSelectionStore.getState().clearAll();
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [inSelectMode, tracklistRef]);
useEffect(() => {
if (!showPlPicker) return;
const handler = (e: MouseEvent) => {
if (!(e.target as HTMLElement).closest('.bulk-pl-picker-wrap')) setShowPlPicker(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showPlPicker]);
// ── Stable callbacks passed to memoised TrackRow ──────────────────────────
const onToggleSelect = useCallback((id: string, globalIdx: number, shift: boolean) => {
useSelectionStore.getState().setSelectedIds(prev => {
const next = new Set(prev);
if (shift && lastSelectedIdxRef.current !== null) {
const from = Math.min(lastSelectedIdxRef.current, globalIdx);
const to = Math.max(lastSelectedIdxRef.current, globalIdx);
songs.slice(from, to + 1).forEach(s => next.add(s.id));
} else {
next.has(id) ? next.delete(id) : next.add(id);
}
lastSelectedIdxRef.current = globalIdx;
return next;
});
}, [songs]);
// Drag: if the dragged song is part of the selection, drag all selected songs.
const onDragStart = useCallback((song: SubsonicSong, me: MouseEvent) => {
const { selectedIds } = useSelectionStore.getState();
if (selectedIds.has(song.id) && selectedIds.size > 1) {
const tracks = songs
.filter(s => selectedIds.has(s.id))
.map(s => songToTrack(s));
psyDrag.startDrag(
{ data: JSON.stringify({ type: 'songs', tracks }), label: `${tracks.length} Songs` },
me.clientX, me.clientY,
);
} else {
psyDrag.startDrag(
{ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title },
me.clientX, me.clientY,
);
}
}, [songs, psyDrag]);
const toggleAll = useCallback(() => {
if (allSelected) {
useSelectionStore.getState().clearAll();
} else {
useSelectionStore.getState().setSelectedIds(() => new Set(songs.map(s => s.id)));
}
}, [allSelected, songs]);
// ── 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 currentTrackId = currentTrack?.id ?? null;
// ── Header cell renderer ──────────────────────────────────────────────────
const renderHeaderCell = (colDef: ColDef, colIndex: number) => {
const key = colDef.key as ColKey;
const isLastCol = colIndex === visibleCols.length - 1;
const isCentered = CENTERED_COLS.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
if (key === 'num') {
return (
<div key={key} className="track-num">
<span
className={`bulk-check${allSelected ? ' checked' : ''}${inSelectMode ? ' 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={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</div>
{hasNextCol && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
)}
</div>
);
}
const isResizable = !isLastCol;
return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<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' }}>{label}</span>
</div>
{isResizable && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
)}
</div>
);
};
// ── Mobile tracklist ──────────────────────────────────────────────────────
if (isMobile) { if (isMobile) {
return ( return (
<div className="tracklist-mobile"> <div className="tracklist-mobile">
@@ -305,7 +440,7 @@ export default function AlbumTrackList({
</div> </div>
)} )}
{discs.get(discNum)!.map(song => { {discs.get(discNum)!.map(song => {
const isActive = currentTrack?.id === song.id; const isActive = currentTrackId === song.id;
return ( return (
<div <div
key={song.id} key={song.id}
@@ -338,13 +473,19 @@ export default function AlbumTrackList({
} }
return ( return (
<div className="tracklist" ref={tracklistRef}> <div
className="tracklist"
ref={tracklistRef}
onClick={e => {
if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll();
}}
>
{/* ── Bulk action bar ── */} {/* ── Bulk action bar ── */}
{inSelectMode && ( {inSelectMode && (
<div className="bulk-action-bar"> <div className="bulk-action-bar">
<span className="bulk-action-count"> <span className="bulk-action-count">
{t('common.bulkSelected', { count: selectedIds.size })} {t('common.bulkSelected', { count: selectedCount })}
</span> </span>
<div className="bulk-pl-picker-wrap"> <div className="bulk-pl-picker-wrap">
<button <button
@@ -356,15 +497,15 @@ export default function AlbumTrackList({
</button> </button>
{showPlPicker && ( {showPlPicker && (
<AddToPlaylistSubmenu <AddToPlaylistSubmenu
songIds={[...selectedIds]} songIds={[...useSelectionStore.getState().selectedIds]}
onDone={() => { setShowPlPicker(false); setSelectedIds(new Set()); }} onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
dropDown dropDown
/> />
)} )}
</div> </div>
<button <button
className="btn btn-ghost btn-sm" className="btn btn-ghost btn-sm"
onClick={() => setSelectedIds(new Set())} onClick={() => useSelectionStore.getState().clearAll()}
> >
<X size={13} /> <X size={13} />
{t('common.bulkClear')} {t('common.bulkClear')}
@@ -420,45 +561,29 @@ export default function AlbumTrackList({
CD {discNum} CD {discNum}
</div> </div>
)} )}
{discs.get(discNum)!.map((song) => { {discs.get(discNum)!.map(song => {
const globalIdx = songs.indexOf(song); const globalIdx = songs.indexOf(song);
return ( return (
<div <TrackRow
key={song.id} key={song.id}
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`} song={song}
style={gridStyle} globalIdx={globalIdx}
onClick={e => { visibleCols={visibleCols}
if ((e.target as HTMLElement).closest('button, a, input')) return; gridStyle={gridStyle}
if (inSelectMode) { currentTrackId={currentTrackId}
toggleSelect(song.id, globalIdx, e.shiftKey); isPlaying={isPlaying}
} else { ratingValue={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
onPlaySong(song); isStarred={starredSongs.has(song.id)}
} inSelectMode={inSelectMode}
}} isContextMenuSong={contextMenuSongId === song.id}
onContextMenu={e => { onPlaySong={onPlaySong}
e.preventDefault(); onRate={onRate}
setContextMenuSongId(song.id); onToggleSongStar={onToggleSongStar}
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song'); onContextMenu={onContextMenu}
}} onToggleSelect={onToggleSelect}
role="row" onDragStart={onDragStart}
onMouseDown={e => { setContextMenuSongId={setContextMenuSongId}
if (e.button !== 0) return; />
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title }, me.clientX, me.clientY);
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
{visibleCols.map(colDef => renderRowCell(colDef.key as ColKey, song, globalIdx))}
</div>
); );
})} })}
</div> </div>
+9 -1
View File
@@ -46,6 +46,7 @@ export default function PlayerBar() {
const navigate = useNavigate(); const navigate = useNavigate();
const [eqOpen, setEqOpen] = useState(false); const [eqOpen, setEqOpen] = useState(false);
const [showVolPct, setShowVolPct] = useState(false); const [showVolPct, setShowVolPct] = useState(false);
const premuteVolumeRef = useRef(1);
const showLyrics = useLyricsStore(s => s.showLyrics); const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab); const activeTab = useLyricsStore(s => s.activeTab);
// currentTime is intentionally excluded — PlaybackTime handles it via direct DOM update. // currentTime is intentionally excluded — PlaybackTime handles it via direct DOM update.
@@ -313,7 +314,14 @@ export default function PlayerBar() {
<div className="player-volume-section"> <div className="player-volume-section">
<button <button
className="player-btn player-btn-sm" className="player-btn player-btn-sm"
onClick={() => setVolume(volume === 0 ? 0.7 : 0)} onClick={() => {
if (volume === 0) {
setVolume(premuteVolumeRef.current);
} else {
premuteVolumeRef.current = volume;
setVolume(0);
}
}}
aria-label={t('player.volume')} aria-label={t('player.volume')}
style={{ color: 'var(--text-muted)', flexShrink: 0 }} style={{ color: 'var(--text-muted)', flexShrink: 0 }}
> >
+12 -4
View File
@@ -272,6 +272,12 @@ export default function QueuePanel() {
const asideRef = useRef<HTMLElement>(null); const asideRef = useRef<HTMLElement>(null);
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop(); const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
/** Only these drag types may be dropped into the queue. */
const QUEUE_DROP_TYPES = new Set(['song', 'album', 'queue_reorder']);
const isQueueDrag = isPsyDragging && !!psyPayload && (() => {
try { return QUEUE_DROP_TYPES.has(JSON.parse(psyPayload.data).type); } catch { return false; }
})();
// Keep for the onPsyDrop radio-reject check below
const isRadioDrag = isPsyDragging && !!psyPayload && (() => { const isRadioDrag = isPsyDragging && !!psyPayload && (() => {
try { return JSON.parse(psyPayload.data).type === 'radio'; } catch { return false; } try { return JSON.parse(psyPayload.data).type === 'radio'; } catch { return false; }
})(); })();
@@ -315,6 +321,8 @@ export default function QueuePanel() {
if (fromIdx !== insertIdx) reorderQueue(fromIdx, insertIdx); if (fromIdx !== insertIdx) reorderQueue(fromIdx, insertIdx);
} else if (parsedData.type === 'song') { } else if (parsedData.type === 'song') {
enqueueAt([parsedData.track], insertIdx); enqueueAt([parsedData.track], insertIdx);
} else if (parsedData.type === 'songs') {
enqueueAt(parsedData.tracks as Track[], insertIdx);
} else if (parsedData.type === 'album') { } else if (parsedData.type === 'album') {
const albumData = await getAlbum(parsedData.id); const albumData = await getAlbum(parsedData.id);
const tracks: Track[] = albumData.songs.map((s: any) => ({ const tracks: Track[] = albumData.songs.map((s: any) => ({
@@ -374,9 +382,9 @@ export default function QueuePanel() {
return ( return (
<aside <aside
ref={asideRef} ref={asideRef}
className={`queue-panel${isPsyDragging && !isRadioDrag ? ' queue-drop-active' : ''}`} className={`queue-panel${isQueueDrag ? ' queue-drop-active' : ''}`}
onMouseMove={e => { onMouseMove={e => {
if (!isPsyDragging || isRadioDrag || !queueListRef.current) return; if (!isQueueDrag || !queueListRef.current) return;
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]'); const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
let found = false; let found = false;
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
@@ -551,9 +559,9 @@ export default function QueuePanel() {
const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded); const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
let dragStyle: React.CSSProperties = {}; let dragStyle: React.CSSProperties = {};
if (isPsyDragging && psyDragFromIdxRef.current === idx) { if (isQueueDrag && psyDragFromIdxRef.current === idx) {
dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' }; dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' };
} else if (isPsyDragging && externalDropTarget?.idx === idx) { } else if (isQueueDrag && externalDropTarget?.idx === idx) {
if (externalDropTarget.before) { if (externalDropTarget.before) {
dragStyle = { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' }; dragStyle = { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' };
} else { } else {
+6
View File
@@ -135,6 +135,7 @@ export const deTranslation = {
goToArtist: 'Zu {{artist}} wechseln', goToArtist: 'Zu {{artist}} wechseln',
moreLabelAlbums: 'Weitere Alben von {{label}} anzeigen', moreLabelAlbums: 'Weitere Alben von {{label}} anzeigen',
trackTitle: 'Titel', trackTitle: 'Titel',
trackAlbum: 'Album',
trackArtist: 'Interpret', trackArtist: 'Interpret',
trackGenre: 'Genre', trackGenre: 'Genre',
trackFormat: 'Format', trackFormat: 'Format',
@@ -148,6 +149,11 @@ export const deTranslation = {
bioClose: 'Schließen', bioClose: 'Schließen',
ratingLabel: 'Bewertung', ratingLabel: 'Bewertung',
enlargeCover: 'Vergrößern', enlargeCover: 'Vergrößern',
filterSongs: 'Titel filtern…',
sortNatural: 'Reihenfolge',
sortByTitle: 'AZ (Titel)',
sortByArtist: 'AZ (Künstler)',
sortByAlbum: 'AZ (Album)',
}, },
entityRating: { entityRating: {
albumShort: 'Albumbewertung', albumShort: 'Albumbewertung',
+6
View File
@@ -136,6 +136,7 @@ export const enTranslation = {
goToArtist: 'Go to {{artist}}', goToArtist: 'Go to {{artist}}',
moreLabelAlbums: 'More albums on {{label}}', moreLabelAlbums: 'More albums on {{label}}',
trackTitle: 'Title', trackTitle: 'Title',
trackAlbum: 'Album',
trackArtist: 'Artist', trackArtist: 'Artist',
trackGenre: 'Genre', trackGenre: 'Genre',
trackFormat: 'Format', trackFormat: 'Format',
@@ -149,6 +150,11 @@ export const enTranslation = {
bioClose: 'Close', bioClose: 'Close',
ratingLabel: 'Rating', ratingLabel: 'Rating',
enlargeCover: 'Enlarge', enlargeCover: 'Enlarge',
filterSongs: 'Filter songs…',
sortNatural: 'Natural',
sortByTitle: 'AZ (Title)',
sortByArtist: 'AZ (Artist)',
sortByAlbum: 'AZ (Album)',
}, },
entityRating: { entityRating: {
albumShort: 'Album rating', albumShort: 'Album rating',
+6
View File
@@ -135,6 +135,7 @@ export const frTranslation = {
goToArtist: 'Aller à {{artist}}', goToArtist: 'Aller à {{artist}}',
moreLabelAlbums: 'Plus d\'albums sur {{label}}', moreLabelAlbums: 'Plus d\'albums sur {{label}}',
trackTitle: 'Titre', trackTitle: 'Titre',
trackAlbum: 'Album',
trackArtist: 'Artiste', trackArtist: 'Artiste',
trackGenre: 'Genre', trackGenre: 'Genre',
trackFormat: 'Format', trackFormat: 'Format',
@@ -148,6 +149,11 @@ export const frTranslation = {
bioClose: 'Fermer', bioClose: 'Fermer',
ratingLabel: 'Note', ratingLabel: 'Note',
enlargeCover: 'Agrandir', enlargeCover: 'Agrandir',
filterSongs: 'Filtrer…',
sortNatural: 'Naturel',
sortByTitle: 'AZ (Titre)',
sortByArtist: 'AZ (Artiste)',
sortByAlbum: 'AZ (Album)',
}, },
entityRating: { entityRating: {
albumShort: 'Note de lalbum', albumShort: 'Note de lalbum',
+6
View File
@@ -135,6 +135,7 @@ export const nbTranslation = {
goToArtist: 'Gå til {{artist}}', goToArtist: 'Gå til {{artist}}',
moreLabelAlbums: 'Flere album på {{label}}', moreLabelAlbums: 'Flere album på {{label}}',
trackTitle: 'Tittel', trackTitle: 'Tittel',
trackAlbum: 'Album',
trackArtist: 'Artist', trackArtist: 'Artist',
trackGenre: 'Sjanger', trackGenre: 'Sjanger',
trackFormat: 'Format', trackFormat: 'Format',
@@ -148,6 +149,11 @@ export const nbTranslation = {
bioClose: 'Lukk', bioClose: 'Lukk',
ratingLabel: 'Vurdering', ratingLabel: 'Vurdering',
enlargeCover: 'Forstørr', enlargeCover: 'Forstørr',
filterSongs: 'Filtrer sanger…',
sortNatural: 'Naturlig',
sortByTitle: 'A–Å (Tittel)',
sortByArtist: 'A–Å (Artist)',
sortByAlbum: 'A–Å (Album)',
}, },
entityRating: { entityRating: {
albumShort: 'Albumvurdering', albumShort: 'Albumvurdering',
+6
View File
@@ -135,6 +135,7 @@ export const nlTranslation = {
goToArtist: 'Naar {{artist}}', goToArtist: 'Naar {{artist}}',
moreLabelAlbums: 'Meer albums op {{label}}', moreLabelAlbums: 'Meer albums op {{label}}',
trackTitle: 'Titel', trackTitle: 'Titel',
trackAlbum: 'Album',
trackArtist: 'Artiest', trackArtist: 'Artiest',
trackGenre: 'Genre', trackGenre: 'Genre',
trackFormat: 'Formaat', trackFormat: 'Formaat',
@@ -148,6 +149,11 @@ export const nlTranslation = {
bioClose: 'Sluiten', bioClose: 'Sluiten',
ratingLabel: 'Beoordeling', ratingLabel: 'Beoordeling',
enlargeCover: 'Vergroten', enlargeCover: 'Vergroten',
filterSongs: 'Filteren…',
sortNatural: 'Natuurlijk',
sortByTitle: 'AZ (Titel)',
sortByArtist: 'AZ (Artiest)',
sortByAlbum: 'AZ (Album)',
}, },
entityRating: { entityRating: {
albumShort: 'Albumbeoordeling', albumShort: 'Albumbeoordeling',
+6
View File
@@ -137,6 +137,7 @@ export const ruTranslation = {
goToArtist: 'Перейти к {{artist}}', goToArtist: 'Перейти к {{artist}}',
moreLabelAlbums: 'Другие альбомы на {{label}}', moreLabelAlbums: 'Другие альбомы на {{label}}',
trackTitle: 'Название', trackTitle: 'Название',
trackAlbum: 'Альбом',
trackArtist: 'Исполнитель', trackArtist: 'Исполнитель',
trackGenre: 'Жанр', trackGenre: 'Жанр',
trackFormat: 'Формат', trackFormat: 'Формат',
@@ -150,6 +151,11 @@ export const ruTranslation = {
bioClose: 'Закрыть', bioClose: 'Закрыть',
ratingLabel: 'Оценка', ratingLabel: 'Оценка',
enlargeCover: 'Увеличить обложку', enlargeCover: 'Увеличить обложку',
filterSongs: 'Фильтр треков…',
sortNatural: 'По умолчанию',
sortByTitle: 'А–Я (название)',
sortByArtist: 'А–Я (исполнитель)',
sortByAlbum: 'А–Я (альбом)',
}, },
entityRating: { entityRating: {
albumShort: 'Оценка альбома', albumShort: 'Оценка альбома',
+6
View File
@@ -135,6 +135,7 @@ export const zhTranslation = {
goToArtist: '前往 {{artist}}', goToArtist: '前往 {{artist}}',
moreLabelAlbums: '{{label}} 的更多专辑', moreLabelAlbums: '{{label}} 的更多专辑',
trackTitle: '标题', trackTitle: '标题',
trackAlbum: '专辑',
trackArtist: '艺术家', trackArtist: '艺术家',
trackGenre: '流派', trackGenre: '流派',
trackFormat: '格式', trackFormat: '格式',
@@ -148,6 +149,11 @@ export const zhTranslation = {
bioClose: '关闭', bioClose: '关闭',
ratingLabel: '评分', ratingLabel: '评分',
enlargeCover: '放大', enlargeCover: '放大',
filterSongs: '筛选歌曲…',
sortNatural: '默认顺序',
sortByTitle: 'AZ(标题)',
sortByArtist: 'AZ(艺术家)',
sortByAlbum: 'AZ(专辑)',
}, },
entityRating: { entityRating: {
albumShort: '专辑评分', albumShort: '专辑评分',
+56 -1
View File
@@ -57,6 +57,9 @@ export default function AlbumDetail() {
const albumEntityRatingSupport = entityRatingSupportByServer[serverId] ?? 'unknown'; const albumEntityRatingSupport = entityRatingSupportByServer[serverId] ?? 'unknown';
const [albumEntityRating, setAlbumEntityRating] = useState(0); const [albumEntityRating, setAlbumEntityRating] = useState(0);
const [filterText, setFilterText] = useState('');
const [sortKey, setSortKey] = useState<'natural' | 'title' | 'artist'>('natural');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
// Derive a stable albumId for the selectors below (empty string when not yet loaded). // Derive a stable albumId for the selectors below (empty string when not yet loaded).
const albumId = album?.album.id ?? ''; const albumId = album?.album.id ?? '';
@@ -259,6 +262,22 @@ const handleEnqueueAll = () => {
deleteAlbum(album.album.id, serverId); deleteAlbum(album.album.id, serverId);
}; };
const displayedSongs = useMemo(() => {
if (!album) return [];
const q = filterText.trim().toLowerCase();
if (!q && sortKey === 'natural') return album.songs;
let result = [...album.songs];
if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q));
if (sortKey !== 'natural') {
result.sort((a, b) => {
const av = sortKey === 'title' ? a.title : (a.artist ?? '');
const bv = sortKey === 'title' ? b.title : (b.artist ?? '');
return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
});
}
return result;
}, [album, filterText, sortKey, sortDir]);
// Hooks must be called unconditionally — derive from nullable album state. // Hooks must be called unconditionally — derive from nullable album state.
// useMemo is required: buildCoverArtUrl generates a new salt on every call, so without // useMemo is required: buildCoverArtUrl generates a new salt on every call, so without
// memoization every re-render (e.g. currentTrack change) produces a new fetchUrl, // memoization every re-render (e.g. currentTrack change) produces a new fetchUrl,
@@ -318,8 +337,44 @@ const handleEnqueueAll = () => {
</div> </div>
)} )}
{songs.length > 0 && (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '0 16px 8px', flexWrap: 'wrap' }}>
<div style={{ position: 'relative', flex: '1 1 160px', maxWidth: 260 }}>
<input
className="input"
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, lineHeight: 1 }}
onClick={() => setFilterText('')}
>×</button>
)}
</div>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{(['natural', 'title', 'artist'] as const).map(key => (
<button
key={key}
className={`btn btn-sm ${sortKey === key ? 'btn-surface' : 'btn-ghost'}`}
onClick={() => {
if (sortKey === key && key !== 'natural') setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortKey(key); setSortDir('asc'); }
}}
>
{key === 'natural' ? t('albumDetail.sortNatural') : key === 'title' ? t('albumDetail.sortByTitle') : t('albumDetail.sortByArtist')}
{sortKey === key && key !== 'natural' && <span style={{ marginLeft: 3 }}>{sortDir === 'asc' ? '↑' : '↓'}</span>}
</button>
))}
</div>
</div>
)}
<AlbumTrackList <AlbumTrackList
songs={songs} songs={displayedSongs}
sorted={sortKey !== 'natural' || !!filterText.trim()}
hasVariousArtists={hasVariousArtists} hasVariousArtists={hasVariousArtists}
currentTrack={currentTrack} currentTrack={currentTrack}
isPlaying={isPlaying} isPlaying={isPlaying}
+80 -24
View File
@@ -113,6 +113,9 @@ export default function PlaylistDetail() {
const [ratings, setRatings] = useState<Record<string, number>>({}); const [ratings, setRatings] = useState<Record<string, number>>({});
const [editingMeta, setEditingMeta] = useState(false); const [editingMeta, setEditingMeta] = useState(false);
const [customCoverId, setCustomCoverId] = useState<string | null>(null); const [customCoverId, setCustomCoverId] = useState<string | null>(null);
const [filterText, setFilterText] = useState('');
const [sortKey, setSortKey] = useState<'natural' | 'title' | 'artist'>('natural');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set()); const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null); const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null); const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
@@ -435,6 +438,7 @@ export default function PlaylistDetail() {
// ── Row mousedown: threshold drag for reorder (from anywhere on the row) ── // ── Row mousedown: threshold drag for reorder (from anywhere on the row) ──
const handleRowMouseDown = (e: React.MouseEvent, idx: number) => { const handleRowMouseDown = (e: React.MouseEvent, idx: number) => {
if (e.button !== 0) return; if (e.button !== 0) return;
if (isFiltered) return;
if ((e.target as HTMLElement).closest('button, input')) return; if ((e.target as HTMLElement).closest('button, input')) return;
e.preventDefault(); e.preventDefault();
const sx = e.clientX, sy = e.clientY; const sx = e.clientX, sy = e.clientY;
@@ -460,6 +464,26 @@ export default function PlaylistDetail() {
const existingIds = useMemo(() => new Set(songs.map(s => s.id)), [songs]); const existingIds = useMemo(() => new Set(songs.map(s => s.id)), [songs]);
const tracks = useMemo(() => songs.map(songToTrack), [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) => {
const av = sortKey === 'title' ? a.title : (a.artist ?? '');
const bv = sortKey === 'title' ? b.title : (b.artist ?? '');
return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
});
}
return result;
}, [songs, filterText, sortKey, sortDir]);
const displayedTracks = useMemo(
() => displayedSongs === songs ? tracks : displayedSongs.map(songToTrack),
[displayedSongs, songs, tracks],
);
const isFiltered = displayedSongs !== songs;
// ── Drag-over visual feedback ───────────────────────────────── // ── Drag-over visual feedback ─────────────────────────────────
const handleRowMouseEnter = (idx: number, e: React.MouseEvent) => { const handleRowMouseEnter = (idx: number, e: React.MouseEvent) => {
if (!isDragging) return; if (!isDragging) return;
@@ -664,6 +688,44 @@ export default function PlaylistDetail() {
</div> </div>
)} )}
{/* ── 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 }}>
<input
className="input"
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, lineHeight: 1 }}
onClick={() => setFilterText('')}
>×</button>
)}
</div>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{(['natural', 'title', 'artist'] as const).map(key => (
<button
key={key}
className={`btn btn-sm ${sortKey === key ? 'btn-surface' : 'btn-ghost'}`}
onClick={() => {
if (sortKey === key && key !== 'natural') setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortKey(key); setSortDir('asc'); }
}}
>
{key === 'natural' ? t('albumDetail.sortNatural')
: key === 'title' ? t('albumDetail.sortByTitle')
: t('albumDetail.sortByArtist')}
{sortKey === key && key !== 'natural' && <span style={{ marginLeft: 3 }}>{sortDir === 'asc' ? '↑' : '↓'}</span>}
</button>
))}
</div>
</div>
)}
{/* ── Tracklist ── */} {/* ── Tracklist ── */}
<div className="tracklist" ref={tracklistRef}> <div className="tracklist" ref={tracklistRef}>
@@ -798,23 +860,25 @@ export default function PlaylistDetail() {
</div> </div>
)} )}
{songs.map((song, idx) => ( {displayedSongs.map((song, i) => {
<React.Fragment key={song.id + idx}> const realIdx = isFiltered ? songs.indexOf(song) : i;
{isDragging && dropTargetIdx?.idx === idx && dropTargetIdx.before && ( return (
<React.Fragment key={song.id + i}>
{!isFiltered && isDragging && dropTargetIdx?.idx === i && dropTargetIdx.before && (
<div className="playlist-drop-indicator" /> <div className="playlist-drop-indicator" />
)} )}
<div <div
data-track-idx={idx} data-track-idx={realIdx}
className={`track-row track-row-va tracklist-playlist${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`} className={`track-row track-row-va tracklist-playlist${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
style={gridStyle} style={gridStyle}
onMouseEnter={e => handleRowMouseEnter(idx, e)} onMouseEnter={e => !isFiltered && handleRowMouseEnter(i, e)}
onMouseDown={e => handleRowMouseDown(e, idx)} onMouseDown={e => handleRowMouseDown(e, realIdx)}
onClick={e => { onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return; if ((e.target as HTMLElement).closest('button, a, input')) return;
if (selectedIds.size > 0) { if (selectedIds.size > 0) {
toggleSelect(song.id, idx, e.shiftKey); toggleSelect(song.id, i, e.shiftKey);
} else { } else {
playTrack(tracks[idx], tracks); playTrack(displayedTracks[i], displayedTracks);
} }
}} }}
onContextMenu={e => { onContextMenu={e => {
@@ -827,11 +891,11 @@ export default function PlaylistDetail() {
const inSelectMode = selectedIds.size > 0; const inSelectMode = selectedIds.size > 0;
switch (colDef.key) { switch (colDef.key) {
case 'num': return ( case 'num': return (
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(tracks[idx], tracks); }}> <div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(displayedTracks[i], displayedTracks); }}>
<span className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`} onClick={e => { e.stopPropagation(); toggleSelect(song.id, idx, e.shiftKey); }} /> <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"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>} {currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span> <span className="track-num-play"><Play size={13} fill="currentColor" /></span>
<span className="track-num-number">{idx + 1}</span> <span className="track-num-number">{i + 1}</span>
</div> </div>
); );
case 'title': return ( case 'title': return (
@@ -858,7 +922,7 @@ export default function PlaylistDetail() {
); );
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(idx); }} 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">
<Trash2 size={13} /> <Trash2 size={13} />
</button> </button>
</div> </div>
@@ -867,22 +931,14 @@ export default function PlaylistDetail() {
} }
})} })}
</div> </div>
{isDragging && dropTargetIdx?.idx === idx && !dropTargetIdx.before && ( {!isFiltered && isDragging && dropTargetIdx?.idx === i && !dropTargetIdx.before && (
<div className="playlist-drop-indicator" /> <div className="playlist-drop-indicator" />
)} )}
</React.Fragment> </React.Fragment>
))} );
})}
{/* Total row */}
{songs.length > 0 && (
<div className="tracklist-total" style={gridStyle}>
{visibleCols.map(c => {
if (c.key === 'title') return <span key="title" className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>;
if (c.key === 'duration') return <span key="duration" className="tracklist-total-value">{formatDuration(songs.reduce((a, s) => a + (s.duration ?? 0), 0))}</span>;
return <span key={c.key} />;
})}
</div>
)}
</div> </div>
{/* ── Suggestions ── */} {/* ── Suggestions ── */}
+35 -22
View File
@@ -102,6 +102,7 @@ const CONTRIBUTORS = [
'Richer star ratings, skip threshold, and library filtering (PR #130)', 'Richer star ratings, skip threshold, and library filtering (PR #130)',
'Statistics: scope album and song totals to selected music library (PR #138)', 'Statistics: scope album and song totals to selected music library (PR #138)',
'AudioMuse-AI discovery integration for Navidrome (PR #147)', 'AudioMuse-AI discovery integration for Navidrome (PR #147)',
'Hot playback cache — eviction budgeting, grace period, and live Audio settings readout (PR #153)',
], ],
}, },
{ {
@@ -1482,12 +1483,16 @@ export default function Settings() {
<Keyboard size={18} /> <Keyboard size={18} />
<h2>{t('settings.tabInput')}</h2> <h2>{t('settings.tabInput')}</h2>
</div> </div>
<div style={{ position: 'relative' }}>
<button
className="btn btn-ghost"
style={{ position: 'absolute', top: -22, right: 0, fontSize: 12, color: 'var(--text-muted)', padding: '2px 4px' }}
onClick={() => { kb.resetToDefaults(); setListeningFor(null); }}
data-tooltip={t('settings.shortcutsReset')}
>
<RotateCcw size={14} />
</button>
<div className="settings-card"> <div className="settings-card">
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
<button className="btn btn-danger" style={{ fontSize: 12 }} onClick={() => { kb.resetToDefaults(); setListeningFor(null); }}>
{t('settings.shortcutsReset')}
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{([ {([
['play-pause', t('settings.shortcutPlayPause')], ['play-pause', t('settings.shortcutPlayPause')],
@@ -1558,6 +1563,7 @@ export default function Settings() {
})} })}
</div> </div>
</div> </div>
</div>
</section> </section>
<section className="settings-section"> <section className="settings-section">
@@ -1568,12 +1574,16 @@ export default function Settings() {
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '12px', lineHeight: 1.5 }}> <p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '12px', lineHeight: 1.5 }}>
{t('settings.globalShortcutsNote')} {t('settings.globalShortcutsNote')}
</p> </p>
<div style={{ position: 'relative' }}>
<button
className="btn btn-ghost"
style={{ position: 'absolute', top: -22, right: 0, fontSize: 12, color: 'var(--text-muted)', padding: '2px 4px' }}
onClick={() => { gs.resetAll(); setListeningForGlobal(null); }}
data-tooltip={t('settings.shortcutsReset')}
>
<RotateCcw size={14} />
</button>
<div className="settings-card"> <div className="settings-card">
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
<button className="btn btn-danger" style={{ fontSize: 12 }} onClick={() => { gs.resetAll(); setListeningForGlobal(null); }}>
{t('settings.shortcutsReset')}
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{([ {([
['play-pause', t('settings.shortcutPlayPause')], ['play-pause', t('settings.shortcutPlayPause')],
@@ -1641,6 +1651,7 @@ export default function Settings() {
})} })}
</div> </div>
</div> </div>
</div>
</section> </section>
</> </>
)} )}
@@ -2042,25 +2053,27 @@ function HomeCustomizer() {
<div className="settings-section-header"> <div className="settings-section-header">
<LayoutGrid size={18} /> <LayoutGrid size={18} />
<h2>{t('settings.homeCustomizerTitle')}</h2> <h2>{t('settings.homeCustomizerTitle')}</h2>
</div>
<div style={{ position: 'relative' }}>
<button <button
className="btn btn-ghost" className="btn btn-ghost"
style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--text-muted)' }} style={{ position: 'absolute', top: -22, right: 0, fontSize: 12, color: 'var(--text-muted)', padding: '2px 4px' }}
onClick={reset} onClick={reset}
data-tooltip={t('settings.sidebarReset')} data-tooltip={t('settings.sidebarReset')}
> >
<RotateCcw size={14} /> <RotateCcw size={14} />
</button> </button>
</div> <div className="settings-card" style={{ padding: '4px 0' }}>
<div className="settings-card" style={{ padding: '4px 0' }}> {sections.map(sec => (
{sections.map(sec => ( <div key={sec.id} className="settings-toggle-row" style={{ padding: '8px 16px' }}>
<div key={sec.id} className="settings-toggle-row" style={{ padding: '8px 16px' }}> <span style={{ fontSize: 14 }}>{SECTION_LABELS[sec.id]}</span>
<span style={{ fontSize: 14 }}>{SECTION_LABELS[sec.id]}</span> <label className="toggle-switch" aria-label={SECTION_LABELS[sec.id]}>
<label className="toggle-switch" aria-label={SECTION_LABELS[sec.id]}> <input type="checkbox" checked={sec.visible} onChange={() => toggleSection(sec.id)} />
<input type="checkbox" checked={sec.visible} onChange={() => toggleSection(sec.id)} /> <span className="toggle-track" />
<span className="toggle-track" /> </label>
</label> </div>
</div> ))}
))} </div>
</div> </div>
</section> </section>
); );
+13
View File
@@ -0,0 +1,13 @@
import { create } from 'zustand';
interface SelectionState {
selectedIds: Set<string>;
setSelectedIds: (update: (prev: Set<string>) => Set<string>) => void;
clearAll: () => void;
}
export const useSelectionStore = create<SelectionState>((set) => ({
selectedIds: new Set<string>(),
setSelectedIds: (update) => set((s) => ({ selectedIds: update(s.selectedIds) })),
clearAll: () => set({ selectedIds: new Set() }),
}));
+6
View File
@@ -567,11 +567,17 @@
font-size: 13px; font-size: 13px;
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 2px; margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.album-card-artist { .album-card-artist {
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.album-card-year { .album-card-year {
+10 -6
View File
@@ -21,25 +21,29 @@ function loadPrefs(
try { try {
const raw = localStorage.getItem(storageKey); const raw = localStorage.getItem(storageKey);
if (!raw) return { widths: defaultWidths, visible: defaultVisible }; if (!raw) return { widths: defaultWidths, visible: defaultVisible };
const parsed = JSON.parse(raw) as { widths?: Record<string, number>; visible?: string[] }; const parsed = JSON.parse(raw) as { widths?: Record<string, number>; visible?: string[]; known?: string[] };
const visible = new Set<string>(parsed.visible ?? [...defaultVisible]); const visible = new Set<string>(parsed.visible ?? [...defaultVisible]);
columns.filter(c => c.required).forEach(c => visible.add(c.key)); columns.filter(c => c.required).forEach(c => visible.add(c.key));
// Auto-show columns that are new since prefs were last saved.
// "known" tracks every column seen at save time; absent = newly added column → default to visible.
if (parsed.known) {
const known = new Set<string>(parsed.known);
columns.filter(c => !c.required && !known.has(c.key)).forEach(c => visible.add(c.key));
}
const widths = { ...defaultWidths, ...(parsed.widths ?? {}) }; const widths = { ...defaultWidths, ...(parsed.widths ?? {}) };
const durationCol = columns.find(c => c.key === 'duration'); const durationCol = columns.find(c => c.key === 'duration');
if (durationCol && typeof widths.duration === 'number' && widths.duration < durationCol.minWidth) { if (durationCol && typeof widths.duration === 'number' && widths.duration < durationCol.minWidth) {
widths.duration = defaultWidths.duration; widths.duration = defaultWidths.duration;
} }
return { return { widths, visible };
widths,
visible,
};
} catch { } catch {
return { widths: defaultWidths, visible: defaultVisible }; return { widths: defaultWidths, visible: defaultVisible };
} }
} }
function savePrefs(storageKey: string, widths: Record<string, number>, visible: Set<string>) { function savePrefs(storageKey: string, widths: Record<string, number>, visible: Set<string>) {
localStorage.setItem(storageKey, JSON.stringify({ widths, visible: [...visible] })); const known = Object.keys(widths);
localStorage.setItem(storageKey, JSON.stringify({ widths, visible: [...visible], known }));
} }
export function useTracklistColumns(columns: readonly ColDef[], storageKey: string) { export function useTracklistColumns(columns: readonly ColDef[], storageKey: string) {