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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-11 11:42:31 +02:00
parent b46acc88e3
commit b9b8f3fc15
16 changed files with 573 additions and 243 deletions
+35 -22
View File
@@ -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() {
<Keyboard size={18} />
<h2>{t('settings.tabInput')}</h2>
</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 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' }}>
{([
['play-pause', t('settings.shortcutPlayPause')],
@@ -1558,6 +1563,7 @@ export default function Settings() {
})}
</div>
</div>
</div>
</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 }}>
{t('settings.globalShortcutsNote')}
</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 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' }}>
{([
['play-pause', t('settings.shortcutPlayPause')],
@@ -1641,6 +1651,7 @@ export default function Settings() {
})}
</div>
</div>
</div>
</section>
</>
)}
@@ -2042,25 +2053,27 @@ function HomeCustomizer() {
<div className="settings-section-header">
<LayoutGrid size={18} />
<h2>{t('settings.homeCustomizerTitle')}</h2>
</div>
<div style={{ position: 'relative' }}>
<button
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}
data-tooltip={t('settings.sidebarReset')}
>
<RotateCcw size={14} />
</button>
</div>
<div className="settings-card" style={{ padding: '4px 0' }}>
{sections.map(sec => (
<div key={sec.id} className="settings-toggle-row" style={{ padding: '8px 16px' }}>
<span style={{ fontSize: 14 }}>{SECTION_LABELS[sec.id]}</span>
<label className="toggle-switch" aria-label={SECTION_LABELS[sec.id]}>
<input type="checkbox" checked={sec.visible} onChange={() => toggleSection(sec.id)} />
<span className="toggle-track" />
</label>
</div>
))}
<div className="settings-card" style={{ padding: '4px 0' }}>
{sections.map(sec => (
<div key={sec.id} className="settings-toggle-row" style={{ padding: '8px 16px' }}>
<span style={{ fontSize: 14 }}>{SECTION_LABELS[sec.id]}</span>
<label className="toggle-switch" aria-label={SECTION_LABELS[sec.id]}>
<input type="checkbox" checked={sec.visible} onChange={() => toggleSection(sec.id)} />
<span className="toggle-track" />
</label>
</div>
))}
</div>
</div>
</section>
);