mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
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:
+310
-185
@@ -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>
|
||||||
|
|||||||
@@ -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 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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: 'A–Z (Titel)',
|
||||||
|
sortByArtist: 'A–Z (Künstler)',
|
||||||
|
sortByAlbum: 'A–Z (Album)',
|
||||||
},
|
},
|
||||||
entityRating: {
|
entityRating: {
|
||||||
albumShort: 'Albumbewertung',
|
albumShort: 'Albumbewertung',
|
||||||
|
|||||||
@@ -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: 'A–Z (Title)',
|
||||||
|
sortByArtist: 'A–Z (Artist)',
|
||||||
|
sortByAlbum: 'A–Z (Album)',
|
||||||
},
|
},
|
||||||
entityRating: {
|
entityRating: {
|
||||||
albumShort: 'Album rating',
|
albumShort: 'Album rating',
|
||||||
|
|||||||
@@ -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: 'A–Z (Titre)',
|
||||||
|
sortByArtist: 'A–Z (Artiste)',
|
||||||
|
sortByAlbum: 'A–Z (Album)',
|
||||||
},
|
},
|
||||||
entityRating: {
|
entityRating: {
|
||||||
albumShort: 'Note de l’album',
|
albumShort: 'Note de l’album',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: 'A–Z (Titel)',
|
||||||
|
sortByArtist: 'A–Z (Artiest)',
|
||||||
|
sortByAlbum: 'A–Z (Album)',
|
||||||
},
|
},
|
||||||
entityRating: {
|
entityRating: {
|
||||||
albumShort: 'Albumbeoordeling',
|
albumShort: 'Albumbeoordeling',
|
||||||
|
|||||||
@@ -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: 'Оценка альбома',
|
||||||
|
|||||||
@@ -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: 'A–Z(标题)',
|
||||||
|
sortByArtist: 'A–Z(艺术家)',
|
||||||
|
sortByAlbum: 'A–Z(专辑)',
|
||||||
},
|
},
|
||||||
entityRating: {
|
entityRating: {
|
||||||
albumShort: '专辑评分',
|
albumShort: '专辑评分',
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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() }),
|
||||||
|
}));
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user