feat: v1.31.0 — AutoEQ, resizable tracklist columns, Discord Listening type, layout fixes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-04 02:04:14 +02:00
parent 463b7483fd
commit c873880a26
31 changed files with 1895 additions and 224 deletions
+5 -1
View File
@@ -81,7 +81,11 @@ function AlbumCard({ album }: AlbumCardProps) {
</div>
<div className="album-card-info">
<p className="album-card-title truncate">{album.name}</p>
<p className="album-card-artist truncate">{album.artist}</p>
<p
className={`album-card-artist truncate${album.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: album.artistId ? 'pointer' : 'default' }}
onClick={e => { if (album.artistId) { e.stopPropagation(); navigate(`/artist/${album.artistId}`); } }}
>{album.artist}</p>
{album.year && <p className="album-card-year">{album.year}</p>}
</div>
</div>
+4 -2
View File
@@ -7,8 +7,10 @@ import CoverLightbox from './CoverLightbox';
import { useTranslation } from 'react-i18next';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
}
+410 -67
View File
@@ -1,22 +1,25 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Play, Heart, ListPlus, X } from 'lucide-react';
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import { Play, Heart, ListPlus, X, ChevronDown, Check } from 'lucide-react';
import { SubsonicSong } from '../api/subsonic';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useDragDrop } from '../contexts/DragDropContext';
import { AddToPlaylistSubmenu } from './ContextMenu';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function codecLabel(song: { suffix?: string; bitRate?: number }): string {
const parts: string[] = [];
if (song.suffix) parts.push(song.suffix.toUpperCase());
if (song.bitRate) parts.push(`${song.bitRate} kbps`);
return parts.join(' · ');
if (song.bitRate) parts.push(`${song.bitRate}`);
return parts.join(' ');
}
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
@@ -42,6 +45,89 @@ function StarRating({ value, onChange }: { value: number; onChange: (r: number)
);
}
// ── Column configuration ──────────────────────────────────────────────────────
const COLUMNS = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true, fixed: true },
{ key: 'title', i18nKey: 'trackTitle', minWidth: 100, defaultWidth: 220, required: true, fixed: false },
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 160, required: false, fixed: false },
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false, fixed: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 100, required: false, fixed: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 60, required: false, fixed: false },
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 80, required: false, fixed: false },
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 80, required: false, fixed: false },
] as const;
type ColKey = (typeof COLUMNS)[number]['key'];
const DEFAULT_WIDTHS: Record<ColKey, number> = Object.fromEntries(
COLUMNS.map(c => [c.key, c.defaultWidth])
) as Record<ColKey, number>;
const DEFAULT_VISIBLE = new Set<ColKey>([
'num', 'title', 'artist', 'favorite', 'rating', 'duration', 'format', 'genre',
]);
function loadColPrefs(): { widths: Record<ColKey, number>; visible: Set<ColKey> } {
try {
const raw = localStorage.getItem('psysonic_tracklist_columns');
if (!raw) return { widths: { ...DEFAULT_WIDTHS }, visible: new Set(DEFAULT_VISIBLE) };
const parsed = JSON.parse(raw);
const visible = new Set<ColKey>((parsed.visible as ColKey[]) ?? [...DEFAULT_VISIBLE]);
COLUMNS.filter(c => c.required).forEach(c => visible.add(c.key as ColKey));
return {
widths: { ...DEFAULT_WIDTHS, ...(parsed.widths ?? {}) },
visible,
};
} catch {
return { widths: { ...DEFAULT_WIDTHS }, visible: new Set(DEFAULT_VISIBLE) };
}
}
function saveColPrefs(widths: Record<ColKey, number>, visible: Set<ColKey>) {
localStorage.setItem('psysonic_tracklist_columns', JSON.stringify({
widths,
visible: [...visible],
}));
}
/** Scale flexible (non-fixed) visible columns proportionally to fill `targetW` exactly.
* Fixed columns (e.g. 'num') keep their width unchanged.
* Each flexible column is clamped to its minWidth; rounding error is absorbed by 'title'. */
function fitColumnsToWidth(
widths: Record<ColKey, number>,
vCols: readonly { readonly key: string; readonly minWidth: number; readonly fixed: boolean }[],
targetW: number,
gapPx: number
): Record<ColKey, number> {
if (vCols.length === 0 || targetW <= 0) return widths;
const next = { ...widths };
const fixedCols = vCols.filter(c => c.fixed);
const flexCols = vCols.filter(c => !c.fixed);
if (flexCols.length === 0) return next;
const totalGaps = Math.max(0, vCols.length - 1) * gapPx;
const fixedTotal = fixedCols.reduce((s, c) => s + (next[c.key as ColKey] ?? c.minWidth), 0);
const available = targetW - totalGaps - fixedTotal;
if (available <= 0) return next;
const currentFlexTotal = flexCols.reduce((s, c) => s + (next[c.key as ColKey] ?? c.minWidth), 0);
if (currentFlexTotal === 0) return next;
const ratio = available / currentFlexTotal;
flexCols.forEach(c => {
const key = c.key as ColKey;
next[key] = Math.max(c.minWidth, Math.round((next[key] ?? c.minWidth) * ratio));
});
// Correct rounding drift in 'title' column
const newFlexTotal = flexCols.reduce((s, c) => s + next[c.key as ColKey], 0);
const diff = available - newFlexTotal;
if (flexCols.some(c => c.key === 'title') && diff !== 0) {
const titleDef = COLUMNS.find(c => c.key === 'title')!;
next['title'] = Math.max(titleDef.minWidth, next['title'] + diff);
}
return next;
}
// ── Props ─────────────────────────────────────────────────────────────────────
interface AlbumTrackListProps {
songs: SubsonicSong[];
hasVariousArtists: boolean;
@@ -68,15 +154,153 @@ export default function AlbumTrackList({
onContextMenu,
}: AlbumTrackListProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const psyDrag = useDragDrop();
// ── Bulk select ───────────────────────────────────────────────────
// ── Bulk select ───────────────────────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
const [showPlPicker, setShowPlPicker] = useState(false);
// ── Column state ──────────────────────────────────────────────────────────
const [colWidths, setColWidths] = useState<Record<ColKey, number>>(() => loadColPrefs().widths);
const [colVisible, setColVisible] = useState<Set<ColKey>>(() => loadColPrefs().visible);
const [pickerOpen, setPickerOpen] = useState(false);
const pickerRef = useRef<HTMLDivElement>(null);
const tracklistRef = useRef<HTMLDivElement>(null);
const prevContainerW = useRef(0);
const colVisibleRef = useRef(colVisible);
useEffect(() => { colVisibleRef.current = colVisible; }, [colVisible]);
// Stores the user's last intentional column widths + the container W they match.
// ResizeObserver always scales FROM this base — never from intermediate scaled values.
// This prevents drift when the window is shrunk and enlarged again.
const baseWidthsRef = useRef<{ widths: Record<ColKey, number>; containerW: number } | null>(null);
// Tracks current colWidths without a useEffect dependency in callbacks
const colWidthsRef = useRef(colWidths);
useEffect(() => { colWidthsRef.current = colWidths; }, [colWidths]);
// On mount: fit saved (or default) widths to current container; establish base.
useLayoutEffect(() => {
const el = tracklistRef.current;
if (!el) return;
const style = getComputedStyle(el);
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
const containerW = el.clientWidth - paddingH;
prevContainerW.current = containerW;
const vCols = COLUMNS.filter(c => colVisibleRef.current.has(c.key));
setColWidths(prev => {
const fitted = fitColumnsToWidth(prev, vCols, containerW, 12);
baseWidthsRef.current = { widths: fitted, containerW };
return fitted;
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// When the container resizes, scale all columns proportionally FROM the base.
// Using the base (not prev) means shrink → grow always returns to exact original widths.
useEffect(() => {
const el = tracklistRef.current;
if (!el) return;
const observer = new ResizeObserver(() => {
const style = getComputedStyle(el);
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
const newW = el.clientWidth - paddingH;
if (Math.abs(newW - prevContainerW.current) < 2) return;
prevContainerW.current = newW;
const base = baseWidthsRef.current;
if (!base) return;
const headerEl = el.querySelector('.tracklist-header') as HTMLElement | null;
const gapPx = headerEl ? (parseFloat(getComputedStyle(headerEl).columnGap) || 12) : 12;
const vCols = COLUMNS.filter(c => colVisibleRef.current.has(c.key));
// Always scale from base.widths, never from current state → no drift
setColWidths(() => fitColumnsToWidth(base.widths, vCols, newW, gapPx));
});
observer.observe(el);
return () => observer.disconnect();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// All visible columns in order
const visibleCols = useMemo(
() => COLUMNS.filter(c => colVisible.has(c.key)),
[colVisible]
);
// Grid template: all fixed px — bidirectional resize works correctly
const gridTemplate = useMemo(
() => visibleCols.map(c => `${colWidths[c.key]}px`).join(' '),
[colWidths, visibleCols]
);
const colStyle = { gridTemplateColumns: gridTemplate };
// ── Bidirectional resize ─────────────────────────────────────────────────
// Dragging the divider between col[colIndex] and col[colIndex+1]:
// → right: colA grows, colB shrinks (clamped to minWidth)
// → left: colA shrinks, colB grows (clamped to minWidth)
// Excel-style resize: only the dragged column changes width.
// Clamped so total never exceeds container width — no overflow, no scrollbar.
const startResize = (e: React.MouseEvent, colIndex: number) => {
e.preventDefault();
e.stopPropagation();
const colA = visibleCols[colIndex];
const defA = COLUMNS.find(c => c.key === colA.key)!;
const startX = e.clientX;
const startW = colWidths[colA.key as ColKey];
const snapshotVisible = colVisible;
// Measure container once at drag start
let maxW = Infinity;
const el = tracklistRef.current;
if (el) {
const style = getComputedStyle(el);
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
const containerW = el.clientWidth - paddingH;
const headerEl = el.querySelector('.tracklist-header') as HTMLElement | null;
const gapPx = headerEl ? (parseFloat(getComputedStyle(headerEl).columnGap) || 0) : 12;
const sumOthers = visibleCols
.filter((_, i) => i !== colIndex)
.reduce((s, c) => s + colWidths[c.key as ColKey], 0);
maxW = Math.max(defA.minWidth, containerW - sumOthers - (visibleCols.length - 1) * gapPx);
}
const onMove = (me: MouseEvent) => {
const newW = Math.min(Math.max(defA.minWidth, startW + me.clientX - startX), maxW);
setColWidths(prev => ({ ...prev, [colA.key]: newW }));
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
// Save final state and update base so future window resizes scale from here
const finalWidths = colWidthsRef.current;
baseWidthsRef.current = { widths: { ...finalWidths }, containerW: prevContainerW.current };
saveColPrefs(finalWidths, snapshotVisible);
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
};
const toggleColumn = (key: ColKey) => {
const def = COLUMNS.find(c => c.key === key)!;
if (def.required) return;
setColVisible(prev => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
saveColPrefs(colWidths, next);
return next;
});
};
const toggleSelect = (id: string, globalIdx: number, shift: boolean) => {
setSelectedIds(prev => {
const next = new Set(prev);
@@ -99,7 +323,6 @@ export default function AlbumTrackList({
if (!contextMenuOpen) setContextMenuSongId(null);
}, [contextMenuOpen]);
// Close playlist picker on outside click
useEffect(() => {
if (!showPlPicker) return;
const handler = (e: MouseEvent) => {
@@ -110,6 +333,15 @@ export default function AlbumTrackList({
return () => document.removeEventListener('mousedown', handler);
}, [showPlPicker]);
useEffect(() => {
if (!pickerOpen) return;
const handler = (e: MouseEvent) => {
if (!pickerRef.current?.contains(e.target as Node)) setPickerOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [pickerOpen]);
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const discs = new Map<number, SubsonicSong[]>();
@@ -123,8 +355,137 @@ export default function AlbumTrackList({
const inSelectMode = selectedIds.size > 0;
// ── Header cell renderer ──────────────────────────────────────────────────
const renderHeaderCell = (colDef: (typeof COLUMNS)[number], colIndex: number) => {
const key = colDef.key as ColKey;
const isLastCol = colIndex === visibleCols.length - 1;
const isCentered = key === 'favorite' || key === 'rating' || key === 'duration';
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
// 'num' header mirrors the row-cell layout exactly so checkbox + # stay aligned
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>
);
}
return (
<div
key={key}
className={isCentered ? 'col-center' : undefined}
style={{ position: 'relative' }}
>
<span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label}
</span>
{/* Resize handle on all non-fixed columns except the last */}
{!isLastCol && !colDef.fixed && (
<div
className="col-resize-handle"
onMouseDown={e => startResize(e, colIndex)}
/>
)}
</div>
);
};
// ── Row cell renderer ─────────────────────────────────────────────────────
const renderRowCell = (key: ColKey, song: SubsonicSong, globalIdx: number) => {
switch (key) {
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(); onPlaySong(song); }}
>
<span
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, 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>
)}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
<span className="track-num-number">{song.track ?? '—'}</span>
</div>
);
case 'title':
return (
<div key="title" className="track-info">
<span className="track-title">{song.title}</span>
</div>
);
case 'artist':
return (
<div key="artist" className="track-artist-cell">
<span
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}
>
{song.artist}
</span>
</div>
);
case 'favorite':
return (
<div key="favorite" className="track-star-cell">
<button
className="btn btn-ghost track-star-btn"
onClick={e => onToggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
>
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button>
</div>
);
case 'rating':
return (
<StarRating
key="rating"
value={ratings[song.id] ?? song.userRating ?? 0}
onChange={r => onRate(song.id, r)}
/>
);
case 'duration':
return (
<div key="duration" className="track-duration">
{formatDuration(song.duration)}
</div>
);
case 'format':
return (
<div key="format" className="track-meta">
{(song.suffix || song.bitRate) && (
<span className="track-codec">{codecLabel(song)}</span>
)}
</div>
);
case 'genre':
return (
<div key="genre" className="track-genre">
{song.genre ?? '—'}
</div>
);
default:
return null;
}
};
return (
<div className="tracklist">
<div className="tracklist" ref={tracklistRef}>
{/* ── Bulk action bar ── */}
{inSelectMode && (
@@ -158,20 +519,47 @@ export default function AlbumTrackList({
</div>
)}
<div className={`tracklist-header${' tracklist-va'}`}>
<div className="col-center">
{inSelectMode
? <span className={`bulk-check${allSelected ? ' checked' : ''}`} onClick={toggleAll} style={{ cursor: 'pointer' }} />
: '#'}
{/* ── Header ── */}
<div style={{ position: 'relative' }}>
<div className="tracklist-header" style={colStyle}>
{visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))}
</div>
{/* Column visibility picker */}
<div className="tracklist-col-picker" ref={pickerRef}>
<button
className="tracklist-col-picker-btn"
onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }}
data-tooltip={t('albumDetail.columns')}
>
<ChevronDown size={14} />
</button>
{pickerOpen && (
<div className="tracklist-col-picker-menu">
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
{COLUMNS.filter(c => !c.required).map(c => {
const key = c.key as ColKey;
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : key;
const isOn = colVisible.has(key);
return (
<button
key={key}
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
onClick={() => toggleColumn(key)}
>
<span className="tracklist-col-picker-check">
{isOn && <Check size={13} />}
</span>
{label}
</button>
);
})}
</div>
)}
</div>
<div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div>
<div className="col-center">{t('albumDetail.trackFavorite')}</div>
<div className="col-center">{t('albumDetail.trackRating')}</div>
<div className="col-center">{t('albumDetail.trackDuration')}</div>
<div>{t('albumDetail.trackFormat')}</div>
</div>
{/* ── Tracks ── */}
{discNums.map(discNum => (
<div key={discNum}>
{isMultiDisc && (
@@ -186,6 +574,7 @@ export default function AlbumTrackList({
<div
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' : ''}`}
style={colStyle}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (inSelectMode) {
@@ -216,59 +605,13 @@ export default function AlbumTrackList({
document.addEventListener('mouseup', onUp);
}}
>
<div
className="track-num"
style={{ cursor: 'pointer' }}
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
>
<span
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }}
/>
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
{currentTrack?.id === song.id && isPlaying
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
: <Play size={13} fill="currentColor" />}
</span>
</div>
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
<div className="track-artist-cell">
<span className="track-artist">{song.artist}</span>
</div>
<div className="track-star-cell">
<button
className="btn btn-ghost track-star-btn"
onClick={e => onToggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
>
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button>
</div>
<StarRating
value={ratings[song.id] ?? song.userRating ?? 0}
onChange={r => onRate(song.id, r)}
/>
<div className="track-duration">
{formatDuration(song.duration)}
</div>
<div className="track-meta">
{(song.suffix || song.bitRate) && (
<span className="track-codec">{codecLabel(song)}</span>
)}
</div>
{visibleCols.map(colDef => renderRowCell(colDef.key as ColKey, song, globalIdx))}
</div>
);
})}
</div>
))}
<div className={`tracklist-total${' tracklist-va'}`}>
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
<span className="tracklist-total-value">{formatDuration(totalDuration)}</span>
</div>
</div>
);
}
+193 -3
View File
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Save, Trash2, RotateCcw } from 'lucide-react';
import { Save, Trash2, RotateCcw, Search, X, ChevronDown, ChevronUp } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import CustomSelect from './CustomSelect';
import { useEqStore, EQ_BANDS, BUILTIN_PRESETS } from '../store/eqStore';
import { useThemeStore } from '../store/themeStore';
@@ -147,7 +148,7 @@ function VerticalFader({ value, disabled, onChange }: FaderProps) {
if (!el) return;
const rect = el.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
const gain = Math.round(pctToGain(pct) / 0.5) * 0.5; // snap to 0.5 dB
const gain = Math.round(pctToGain(pct) / 0.1) * 0.1; // snap to 0.1 dB
onChange(Math.max(GAIN_MIN, Math.min(GAIN_MAX, gain)));
}, [onChange]);
@@ -182,20 +183,63 @@ function VerticalFader({ value, disabled, onChange }: FaderProps) {
);
}
// ─── AutoEQ helpers ───────────────────────────────────────────────────────────
interface AutoEqVariant { form: string; rig: string | null; source: string; }
interface AutoEqResult { name: string; source: string; rig: string | null; form: string; }
function parseGraphicEqString(graphicEqStr: string): number[] {
const line = graphicEqStr.replace(/^GraphicEQ:\s*/i, '');
const points: [number, number][] = line
.split(';')
.map(s => s.trim())
.filter(Boolean)
.map(s => { const [f, g] = s.split(/\s+/).map(Number); return [f, g] as [number, number]; })
.filter(([f, g]) => !isNaN(f) && !isNaN(g));
if (points.length === 0) return [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
return [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000].map(targetFreq => {
if (targetFreq <= points[0][0]) return points[0][1];
if (targetFreq >= points[points.length - 1][0]) return points[points.length - 1][1];
for (let i = 0; i < points.length - 1; i++) {
if (points[i][0] <= targetFreq && points[i + 1][0] >= targetFreq) {
const lo = points[i], hi = points[i + 1];
if (lo[0] === hi[0]) return lo[1];
const t = (Math.log10(targetFreq) - Math.log10(lo[0])) / (Math.log10(hi[0]) - Math.log10(lo[0]));
return Math.round((lo[1] + t * (hi[1] - lo[1])) / 0.1) * 0.1;
}
}
return 0;
});
}
// ─── Main component ───────────────────────────────────────────────────────────
export default function Equalizer() {
const { t } = useTranslation();
const gains = useEqStore(s => s.gains);
const enabled = useEqStore(s => s.enabled);
const preGain = useEqStore(s => s.preGain);
const activePreset = useEqStore(s => s.activePreset);
const customPresets = useEqStore(s => s.customPresets);
const { setBandGain, setEnabled, applyPreset, saveCustomPreset, deleteCustomPreset } = useEqStore();
const { setBandGain, setEnabled, setPreGain, applyPreset, applyAutoEq, saveCustomPreset, deleteCustomPreset } = useEqStore();
const [saveName, setSaveName] = useState('');
const [showSave, setShowSave] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
// AutoEQ state
const [autoEqOpen, setAutoEqOpen] = useState(false);
const [autoEqQuery, setAutoEqQuery] = useState('');
const [autoEqResults, setAutoEqResults] = useState<AutoEqResult[]>([]);
const [autoEqLoading, setAutoEqLoading] = useState(false);
const [autoEqError, setAutoEqError] = useState<string | null>(null);
const [autoEqApplied, setAutoEqApplied] = useState<string | null>(null);
const [entriesLoading, setEntriesLoading] = useState(false);
const entriesCacheRef = useRef<Record<string, AutoEqVariant[]> | null>(null);
const theme = useThemeStore(s => s.theme);
const redraw = useCallback(() => {
@@ -216,6 +260,63 @@ export default function Equalizer() {
return () => ro.disconnect();
}, [redraw]);
// AutoEQ: load entries index lazily when section opens, then filter client-side
async function ensureEntries() {
if (entriesCacheRef.current) return;
setEntriesLoading(true);
setAutoEqError(null);
try {
const json = await invoke<string>('autoeq_entries');
entriesCacheRef.current = JSON.parse(json);
} catch (e: unknown) {
setAutoEqError(e instanceof Error ? e.message : t('settings.eqAutoEqError'));
} finally {
setEntriesLoading(false);
}
}
useEffect(() => {
const q = autoEqQuery.trim().toLowerCase();
if (!entriesCacheRef.current || q.length < 1) { setAutoEqResults([]); return; }
const flat: AutoEqResult[] = [];
for (const [name, variants] of Object.entries(entriesCacheRef.current)) {
if (!name.toLowerCase().includes(q)) continue;
for (const v of variants) {
flat.push({ name, source: v.source, rig: v.rig, form: v.form });
if (flat.length >= 20) break;
}
if (flat.length >= 20) break;
}
setAutoEqResults(flat);
// entriesLoading in deps: re-runs after entries finish loading so a query typed
// during loading produces results immediately without needing a re-type.
}, [autoEqQuery, entriesLoading]);
async function applyAutoEqResult(result: AutoEqResult) {
setAutoEqLoading(true);
setAutoEqError(null);
try {
const text = await invoke<string>('autoeq_fetch_profile', {
name: result.name,
source: result.source,
rig: result.rig ?? null,
form: result.form,
});
if (!text) throw new Error(t('settings.eqAutoEqFetchError'));
const newGains = parseGraphicEqString(text);
// autoeq.app normalizes gains (preamp baked in) — apply with 0 pre-gain
applyAutoEq(result.name, newGains, 0);
setAutoEqApplied(result.name);
setAutoEqQuery('');
setAutoEqResults([]);
setTimeout(() => setAutoEqApplied(null), 3000);
} catch (e: unknown) {
setAutoEqError(e instanceof Error ? e.message : t('settings.eqAutoEqFetchError'));
} finally {
setAutoEqLoading(false);
}
}
const allPresets = [...BUILTIN_PRESETS, ...customPresets];
const selectValue = activePreset ?? '__custom__';
const isCustomSaved = activePreset && !BUILTIN_PRESETS.some(p => p.name === activePreset);
@@ -279,6 +380,74 @@ export default function Equalizer() {
</div>
)}
{/* AutoEQ section */}
<div className="eq-autoeq-section">
<button
className="eq-autoeq-toggle"
onClick={() => {
const opening = !autoEqOpen;
setAutoEqOpen(opening);
setAutoEqQuery('');
setAutoEqResults([]);
setAutoEqError(null);
if (opening) ensureEntries();
}}
>
<Search size={13} />
<span>{t('settings.eqAutoEqTitle')}</span>
{autoEqOpen ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
</button>
{autoEqOpen && (
<div className="eq-autoeq-body">
<div className="eq-autoeq-search-row">
<input
className="input"
placeholder={t('settings.eqAutoEqPlaceholder')}
value={autoEqQuery}
onChange={e => { setAutoEqQuery(e.target.value); setAutoEqError(null); }}
autoFocus
style={{ flex: 1, padding: '6px 12px', fontSize: 13 }}
/>
{autoEqQuery && (
<button className="eq-ctrl-btn" onClick={() => { setAutoEqQuery(''); setAutoEqResults([]); }}>
<X size={13} />
</button>
)}
</div>
{(entriesLoading || autoEqLoading) && (
<div className="eq-autoeq-status">{t('settings.eqAutoEqSearching')}</div>
)}
{autoEqError && (
<div className="eq-autoeq-status eq-autoeq-error">{autoEqError}</div>
)}
{autoEqApplied && (
<div className="eq-autoeq-status eq-autoeq-applied"> {autoEqApplied}</div>
)}
{autoEqResults.length > 0 && (
<div className="eq-autoeq-results">
{autoEqResults.map((r, i) => (
<button
key={`${r.name}|${r.source}|${i}`}
className="eq-autoeq-result-btn"
onClick={() => applyAutoEqResult(r)}
>
<span>{r.name}</span>
<span className="eq-autoeq-result-source">{r.source}</span>
</button>
))}
</div>
)}
{!entriesLoading && !autoEqLoading && !autoEqError && autoEqQuery.length >= 2 && autoEqResults.length === 0 && (
<div className="eq-autoeq-status">{t('settings.eqAutoEqNoResults')}</div>
)}
</div>
)}
</div>
{/* EQ panel */}
<div className={`eq-panel ${!enabled ? 'eq-panel--off' : ''}`}>
{/* Frequency response */}
@@ -313,6 +482,27 @@ export default function Equalizer() {
</div>
))}
</div>
{/* Pre-gain row */}
<div className="eq-pregain-row">
<span className="eq-pregain-label">{t('settings.eqPreGain')}</span>
<input
type="range"
className="eq-pregain-slider"
min={-30} max={6} step={0.1}
value={preGain}
disabled={!enabled}
onChange={e => setPreGain(parseFloat(e.target.value))}
/>
<span className="eq-pregain-val">
{preGain > 0 ? '+' : ''}{preGain.toFixed(1)} dB
</span>
{preGain !== 0 && (
<button className="eq-ctrl-btn" onClick={() => setPreGain(0)} data-tooltip={t('settings.eqResetPreGain')} style={{ marginLeft: 4 }}>
<RotateCcw size={11} />
</button>
)}
</div>
</div>
</div>
);
+57 -23
View File
@@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal, Cast
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
@@ -31,7 +31,7 @@ export default function PlayerBar() {
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
const {
currentTrack, isPlaying, currentTime, volume,
currentTrack, currentRadio, isPlaying, currentTime, volume,
togglePlay, next, previous, setVolume,
stop, toggleRepeat, repeatMode, toggleFullscreen,
lastfmLoved, toggleLastfmLove,
@@ -40,6 +40,8 @@ export default function PlayerBar() {
} = usePlayerStore();
const { lastfmSessionKey } = useAuthStore();
const isRadio = !!currentRadio;
const isStarred = currentTrack
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
: false;
@@ -57,6 +59,13 @@ export default function PlayerBar() {
}, [currentTrack, isStarred, setStarredOverride]);
const duration = currentTrack?.duration ?? 0;
// Cover art: prefer radio station art, fall back to track art.
const radioCoverSrc = useMemo(
() => currentRadio?.coverArt ? buildCoverArtUrl(currentRadio.coverArt, 128) : '',
[currentRadio?.coverArt]
);
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(currentRadio.coverArt, 128) : '';
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '';
@@ -80,11 +89,24 @@ export default function PlayerBar() {
{/* Track Info */}
<div className="player-track-info">
<div
className={`player-album-art-wrap ${currentTrack ? 'clickable' : ''}`}
onClick={() => currentTrack && toggleFullscreen()}
data-tooltip={currentTrack ? t('player.openFullscreen') : undefined}
className={`player-album-art-wrap ${currentTrack && !isRadio ? 'clickable' : ''}`}
onClick={() => !isRadio && currentTrack && toggleFullscreen()}
data-tooltip={!isRadio && currentTrack ? t('player.openFullscreen') : undefined}
>
{currentTrack?.coverArt ? (
{isRadio ? (
currentRadio?.coverArt ? (
<CachedImage
className="player-album-art"
src={radioCoverSrc}
cacheKey={radioCoverKey}
alt={currentRadio.name}
/>
) : (
<div className="player-album-art-placeholder">
<Cast size={20} />
</div>
)
) : currentTrack?.coverArt ? (
<CachedImage
className="player-album-art"
src={coverSrc}
@@ -96,7 +118,7 @@ export default function PlayerBar() {
<Music size={22} />
</div>
)}
{currentTrack && (
{currentTrack && !isRadio && (
<div className="player-art-expand-hint" aria-hidden="true">
<Maximize2 size={16} />
</div>
@@ -104,19 +126,19 @@ export default function PlayerBar() {
</div>
<div className="player-track-meta">
<MarqueeText
text={currentTrack?.title ?? t('player.noTitle')}
text={isRadio ? (currentRadio?.name ?? '—') : (currentTrack?.title ?? t('player.noTitle'))}
className="player-track-name"
style={{ cursor: currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
style={{ cursor: !isRadio && currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => !isRadio && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
/>
<MarqueeText
text={currentTrack?.artist ?? '—'}
text={isRadio ? t('radio.liveStream') : (currentTrack?.artist ?? '—')}
className="player-track-artist"
style={{ cursor: currentTrack?.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
style={{ cursor: !isRadio && currentTrack?.artistId ? 'pointer' : 'default' }}
onClick={() => !isRadio && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
/>
</div>
{currentTrack && (
{currentTrack && !isRadio && (
<button
className="player-btn player-btn-sm player-star-btn"
onClick={toggleStar}
@@ -127,7 +149,7 @@ export default function PlayerBar() {
<Heart size={15} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
{currentTrack && lastfmSessionKey && (
{currentTrack && !isRadio && lastfmSessionKey && (
<button
className="player-btn player-btn-sm player-love-btn"
onClick={toggleLastfmLove}
@@ -145,7 +167,7 @@ export default function PlayerBar() {
<button className="player-btn player-btn-sm" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
<Square size={14} fill="currentColor" />
</button>
<button className="player-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
<button className="player-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')} disabled={isRadio} style={isRadio ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
<SkipBack size={19} />
</button>
<button
@@ -156,7 +178,7 @@ export default function PlayerBar() {
>
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
</button>
<button className="player-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<button className="player-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')} disabled={isRadio} style={isRadio ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
<SkipForward size={19} />
</button>
<button
@@ -170,13 +192,25 @@ export default function PlayerBar() {
</button>
</div>
{/* Waveform Seekbar */}
{/* Waveform Seekbar / Radio live bar */}
<div className="player-waveform-section">
<span className="player-time">{formatTime(currentTime)}</span>
<div className="player-waveform-wrap">
<WaveformSeek trackId={currentTrack?.id} />
</div>
<span className="player-time">{formatTime(duration)}</span>
{isRadio ? (
<>
<span className="player-time">{formatTime(currentTime)}</span>
<div className="player-waveform-wrap" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span className="radio-live-badge">{t('radio.live')}</span>
</div>
<span className="player-time" style={{ opacity: 0 }}>0:00</span>
</>
) : (
<>
<span className="player-time">{formatTime(currentTime)}</span>
<div className="player-waveform-wrap">
<WaveformSeek trackId={currentTrack?.id} />
</div>
<span className="player-time">{formatTime(duration)}</span>
</>
)}
</div>
{/* Lyrics Button */}
+2 -1
View File
@@ -7,7 +7,7 @@ import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast
} from 'lucide-react';
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
@@ -24,6 +24,7 @@ export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey:
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix', section: 'library' },
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
// radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' }, // TODO: unhide when radio is ready
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
};