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
+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>
);