diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index c3dc6837..520969d6 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -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 { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns'; import { SubsonicSong } from '../api/subsonic'; @@ -9,6 +9,7 @@ import { useDragDrop } from '../contexts/DragDropContext'; import { AddToPlaylistSubmenu } from './ContextMenu'; import { useIsMobile } from '../hooks/useIsMobile'; import StarRating from './StarRating'; +import { useSelectionStore } from '../store/selectionStore'; function formatDuration(seconds: number): string { const h = Math.floor(seconds / 3600); @@ -26,10 +27,6 @@ function codecLabel(song: { suffix?: string; bitRate?: number }): string { } // ── 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[] = [ { key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: 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'; -// Columns where header label is centred in the cell (matches row controls below) const CENTERED_COLS = new Set(['favorite', 'rating', 'duration']); // ── Props ───────────────────────────────────────────────────────────────────── interface AlbumTrackListProps { songs: SubsonicSong[]; + sorted?: boolean; hasVariousArtists: boolean; currentTrack: Track | null; isPlaying: boolean; ratings: Record; - /** Merged after local `ratings` (e.g. skip→1★ optimistic updates). */ userRatingOverrides: Record; starredSongs: Set; 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; } -export default function AlbumTrackList({ - songs, - hasVariousArtists, - currentTrack, +// ── TrackRow (memoised) ─────────────────────────────────────────────────────── +// Subscribes only to its own boolean in the selection store → O(1) re-render on toggle. + +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, - ratings, - userRatingOverrides, - starredSongs, + ratingValue, + isStarred, + inSelectMode, + isContextMenuSong, onPlaySong, onRate, onToggleSongStar, onContextMenu, -}: AlbumTrackListProps) { + onToggleSelect, + onDragStart, + setContextMenuSongId, +}: TrackRowProps) { const { t } = useTranslation(); const navigate = useNavigate(); - const isMobile = useIsMobile(); - const [contextMenuSongId, setContextMenuSongId] = useState(null); - const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); - const psyDrag = useDragDrop(); + // Fine-grained: only re-renders when THIS row's selection boolean flips. + const isSelected = useSelectionStore(s => s.selectedIds.has(song.id)); + const isActive = currentTrackId === song.id; - // ── Bulk select ─────────────────────────────────────────────────────────── - const [selectedIds, setSelectedIds] = useState>(new Set()); - const [lastSelectedIdx, setLastSelectedIdx] = useState(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(); - 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 renderCell = (colDef: ColDef) => { 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 ( -
- { e.stopPropagation(); toggleAll(); }} - style={{ cursor: 'pointer' }} - /> - # -
- ); - } - - // 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 ( -
-
- {label} -
- {hasNextCol && ( -
startResize(e, colIndex + 1, -1)} /> - )} -
- ); - } - - // px-width columns: centred (compact controls) or left-aligned label + right-edge divider - const isResizable = !isLastCol; - return ( -
-
- {label} -
- {isResizable && ( -
startResize(e, colIndex, 1)} /> - )} -
- ); - }; - - // ── Row cell renderer ───────────────────────────────────────────────────── - const renderRowCell = (key: ColKey, song: SubsonicSong, globalIdx: number) => { switch (key) { case 'num': return (
{ e.stopPropagation(); onPlaySong(song); }} > { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }} + className={`bulk-check${isSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`} + onClick={e => { e.stopPropagation(); onToggleSelect(song.id, globalIdx, e.shiftKey); }} /> - {currentTrack?.id === song.id && isPlaying && ( + {isActive && isPlaying && (
@@ -252,11 +162,11 @@ export default function AlbumTrackList({ return (
); @@ -264,7 +174,7 @@ export default function AlbumTrackList({ return ( onRate(song.id, r)} /> ); @@ -293,7 +203,232 @@ export default function AlbumTrackList({ } }; - // ── Mobile tracklist ───────────────────────────────────────────────────── + return ( +
{ + 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))} +
+ ); +}); + +// ── 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(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(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(); + 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 ( +
+ { e.stopPropagation(); toggleAll(); }} + style={{ cursor: 'pointer' }} + /> + # +
+ ); + } + + if (key === 'title') { + const hasNextCol = colIndex + 1 < visibleCols.length; + return ( +
+
+ {label} +
+ {hasNextCol && ( +
startResize(e, colIndex + 1, -1)} /> + )} +
+ ); + } + + const isResizable = !isLastCol; + return ( +
+
+ {label} +
+ {isResizable && ( +
startResize(e, colIndex, 1)} /> + )} +
+ ); + }; + + // ── Mobile tracklist ────────────────────────────────────────────────────── if (isMobile) { return (
@@ -305,7 +440,7 @@ export default function AlbumTrackList({
)} {discs.get(discNum)!.map(song => { - const isActive = currentTrack?.id === song.id; + const isActive = currentTrackId === song.id; return (
+
{ + if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll(); + }} + > {/* ── Bulk action bar ── */} {inSelectMode && (
- {t('common.bulkSelected', { count: selectedIds.size })} + {t('common.bulkSelected', { count: selectedCount })}
)} - {discs.get(discNum)!.map((song) => { + {discs.get(discNum)!.map(song => { const globalIdx = songs.indexOf(song); return ( -
{ - if ((e.target as HTMLElement).closest('button, a, input')) return; - if (inSelectMode) { - toggleSelect(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); - 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))} -
+ song={song} + globalIdx={globalIdx} + visibleCols={visibleCols} + gridStyle={gridStyle} + currentTrackId={currentTrackId} + isPlaying={isPlaying} + ratingValue={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0} + isStarred={starredSongs.has(song.id)} + inSelectMode={inSelectMode} + isContextMenuSong={contextMenuSongId === song.id} + onPlaySong={onPlaySong} + onRate={onRate} + onToggleSongStar={onToggleSongStar} + onContextMenu={onContextMenu} + onToggleSelect={onToggleSelect} + onDragStart={onDragStart} + setContextMenuSongId={setContextMenuSongId} + /> ); })}
diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index ed34937c..24449989 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -46,6 +46,7 @@ export default function PlayerBar() { const navigate = useNavigate(); const [eqOpen, setEqOpen] = useState(false); const [showVolPct, setShowVolPct] = useState(false); + const premuteVolumeRef = useRef(1); const showLyrics = useLyricsStore(s => s.showLyrics); const activeTab = useLyricsStore(s => s.activeTab); // currentTime is intentionally excluded — PlaybackTime handles it via direct DOM update. @@ -313,7 +314,14 @@ export default function PlayerBar() {
)} + {songs.length > 0 && ( +
+
+ setFilterText(e.target.value)} + /> + {filterText && ( + + )} +
+
+ {(['natural', 'title', 'artist'] as const).map(key => ( + + ))} +
+
+ )} + >({}); const [editingMeta, setEditingMeta] = useState(false); const [customCoverId, setCustomCoverId] = useState(null); + const [filterText, setFilterText] = useState(''); + const [sortKey, setSortKey] = useState<'natural' | 'title' | 'artist'>('natural'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); const [starredSongs, setStarredSongs] = useState>(new Set()); const [hoveredSuggestionId, setHoveredSuggestionId] = useState(null); const [contextMenuSongId, setContextMenuSongId] = useState(null); @@ -435,6 +438,7 @@ export default function PlaylistDetail() { // ── Row mousedown: threshold drag for reorder (from anywhere on the row) ── const handleRowMouseDown = (e: React.MouseEvent, idx: number) => { if (e.button !== 0) return; + if (isFiltered) return; if ((e.target as HTMLElement).closest('button, input')) return; e.preventDefault(); 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 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 ───────────────────────────────── const handleRowMouseEnter = (idx: number, e: React.MouseEvent) => { if (!isDragging) return; @@ -664,6 +688,44 @@ export default function PlaylistDetail() {
)} + {/* ── Filter / sort toolbar ── */} + {songs.length > 0 && ( +
+
+ setFilterText(e.target.value)} + /> + {filterText && ( + + )} +
+
+ {(['natural', 'title', 'artist'] as const).map(key => ( + + ))} +
+
+ )} + {/* ── Tracklist ── */}
@@ -798,23 +860,25 @@ export default function PlaylistDetail() {
)} - {songs.map((song, idx) => ( - - {isDragging && dropTargetIdx?.idx === idx && dropTargetIdx.before && ( + {displayedSongs.map((song, i) => { + const realIdx = isFiltered ? songs.indexOf(song) : i; + return ( + + {!isFiltered && isDragging && dropTargetIdx?.idx === i && dropTargetIdx.before && (
)}
handleRowMouseEnter(idx, e)} - onMouseDown={e => handleRowMouseDown(e, idx)} + onMouseEnter={e => !isFiltered && handleRowMouseEnter(i, e)} + onMouseDown={e => handleRowMouseDown(e, realIdx)} onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; if (selectedIds.size > 0) { - toggleSelect(song.id, idx, e.shiftKey); + toggleSelect(song.id, i, e.shiftKey); } else { - playTrack(tracks[idx], tracks); + playTrack(displayedTracks[i], displayedTracks); } }} onContextMenu={e => { @@ -827,11 +891,11 @@ export default function PlaylistDetail() { const inSelectMode = selectedIds.size > 0; switch (colDef.key) { case 'num': return ( -
{ e.stopPropagation(); playTrack(tracks[idx], tracks); }}> - { e.stopPropagation(); toggleSelect(song.id, idx, e.shiftKey); }} /> +
{ e.stopPropagation(); playTrack(displayedTracks[i], displayedTracks); }}> + { e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} /> {currentTrack?.id === song.id && isPlaying &&
} - {idx + 1} + {i + 1}
); case 'title': return ( @@ -858,7 +922,7 @@ export default function PlaylistDetail() { ); case 'delete': return (
-
@@ -867,22 +931,14 @@ export default function PlaylistDetail() { } })}
- {isDragging && dropTargetIdx?.idx === idx && !dropTargetIdx.before && ( + {!isFiltered && isDragging && dropTargetIdx?.idx === i && !dropTargetIdx.before && (
)} - ))} + ); + })} + - {/* Total row */} - {songs.length > 0 && ( -
- {visibleCols.map(c => { - if (c.key === 'title') return {t('albumDetail.trackTotal')}; - if (c.key === 'duration') return {formatDuration(songs.reduce((a, s) => a + (s.duration ?? 0), 0))}; - return ; - })} -
- )}
{/* ── Suggestions ── */} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 91e68f1a..348cbe24 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -102,6 +102,7 @@ const CONTRIBUTORS = [ 'Richer star ratings, skip threshold, and library filtering (PR #130)', 'Statistics: scope album and song totals to selected music library (PR #138)', '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() {

{t('settings.tabInput')}

+
+
-
- -
{([ ['play-pause', t('settings.shortcutPlayPause')], @@ -1558,6 +1563,7 @@ export default function Settings() { })}
+
@@ -1568,12 +1574,16 @@ export default function Settings() {

{t('settings.globalShortcutsNote')}

+
+
-
- -
{([ ['play-pause', t('settings.shortcutPlayPause')], @@ -1641,6 +1651,7 @@ export default function Settings() { })}
+
)} @@ -2042,25 +2053,27 @@ function HomeCustomizer() {

{t('settings.homeCustomizerTitle')}

+
+
-
-
- {sections.map(sec => ( -
- {SECTION_LABELS[sec.id]} - -
- ))} +
+ {sections.map(sec => ( +
+ {SECTION_LABELS[sec.id]} + +
+ ))} +
); diff --git a/src/store/selectionStore.ts b/src/store/selectionStore.ts new file mode 100644 index 00000000..309c94f1 --- /dev/null +++ b/src/store/selectionStore.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand'; + +interface SelectionState { + selectedIds: Set; + setSelectedIds: (update: (prev: Set) => Set) => void; + clearAll: () => void; +} + +export const useSelectionStore = create((set) => ({ + selectedIds: new Set(), + setSelectedIds: (update) => set((s) => ({ selectedIds: update(s.selectedIds) })), + clearAll: () => set({ selectedIds: new Set() }), +})); diff --git a/src/styles/components.css b/src/styles/components.css index e5dcc511..468a37ea 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -567,11 +567,17 @@ font-size: 13px; color: var(--text-primary); margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .album-card-artist { font-size: 12px; color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .album-card-year { diff --git a/src/utils/useTracklistColumns.ts b/src/utils/useTracklistColumns.ts index 2fd7d6da..f9bb851d 100644 --- a/src/utils/useTracklistColumns.ts +++ b/src/utils/useTracklistColumns.ts @@ -21,25 +21,29 @@ function loadPrefs( try { const raw = localStorage.getItem(storageKey); if (!raw) return { widths: defaultWidths, visible: defaultVisible }; - const parsed = JSON.parse(raw) as { widths?: Record; visible?: string[] }; + const parsed = JSON.parse(raw) as { widths?: Record; visible?: string[]; known?: string[] }; const visible = new Set(parsed.visible ?? [...defaultVisible]); 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(parsed.known); + columns.filter(c => !c.required && !known.has(c.key)).forEach(c => visible.add(c.key)); + } const widths = { ...defaultWidths, ...(parsed.widths ?? {}) }; const durationCol = columns.find(c => c.key === 'duration'); if (durationCol && typeof widths.duration === 'number' && widths.duration < durationCol.minWidth) { widths.duration = defaultWidths.duration; } - return { - widths, - visible, - }; + return { widths, visible }; } catch { return { widths: defaultWidths, visible: defaultVisible }; } } function savePrefs(storageKey: string, widths: Record, visible: Set) { - 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) {